Config-Abstraction

 view release on metacpan or  search on metacpan

t/edge_cases.t  view on Meta::CPAN

	local %ENV = %ENV;
	$ENV{"${ENV_PREFIX}DATABASE__USER"} = '';

	my $cfg = Config::Abstraction->new(
		data => {
			database => { user => $EXPECTED_USER, pass => 'x' },
		},
		config_dirs => [],
		env_prefix  => $ENV_PREFIX,
	);
	my $val = $cfg->get('database.user');
	# Empty string ENV value should override, not be ignored
	ok(defined($val), 'empty ENV value is defined');
	is($val, '', 'empty ENV value overrides data');
};

subtest 'ENV - prefix match is case-insensitive per POD' => sub {
	local %ENV = %ENV;
	$ENV{lc("${ENV_PREFIX}DATABASE__USER")} = 'lower_env';

	my $cfg = Config::Abstraction->new(
		data => {
			database => { user => $EXPECTED_USER, pass => 'x' },
		},
		config_dirs => [],
		env_prefix  => $ENV_PREFIX,
	);
	# POD says case-insensitive match
	is($cfg->get('database.user'), 'lower_env', 'lowercase ENV key matched case-insensitively');
};

subtest 'ENV - many double-underscore segments create deep nesting' => sub {
	local %ENV = %ENV;
	$ENV{"${ENV_PREFIX}A__B__C__D"} = 'deep';

	my $cfg = Config::Abstraction->new(
		data        => { a => { b => { c => { d => 'original' } } } },
		config_dirs => [],
		env_prefix  => $ENV_PREFIX,
	);
	is($cfg->get('a.b.c.d'), 'deep', 'deep double-underscore ENV nesting works');
};

subtest 'ENV - prefix with no matching vars leaves data intact' => sub {
	local %ENV = %ENV;
	# Remove any accidentally matching vars
	delete $ENV{$_} for grep { /^$ENV_PREFIX/ } keys %ENV;

	my $cfg = Config::Abstraction->new(
		data        => { key => 'original' },
		config_dirs => [],
		env_prefix  => $ENV_PREFIX,
	);
	is($cfg->get('key'), 'original', 'data intact when no ENV vars match prefix');
};

# ===========================================================================
# Pathological CLI argument edge cases
# ===========================================================================
subtest 'CLI - arg without = sign is ignored' => sub {
	local @ARGV = ("--${ENV_PREFIX}RETRIES");

	my $cfg = Config::Abstraction->new(
		data        => { retries => 3 },
		config_dirs => [],
		env_prefix  => $ENV_PREFIX,
	);
	is($cfg->get('retries'), 3, 'CLI arg without = sign ignored');
};

subtest 'CLI - arg with empty value sets empty string' => sub {
	local @ARGV = ("--${ENV_PREFIX}RETRIES=");

	my $cfg = Config::Abstraction->new(
		data        => { retries => 3 },
		config_dirs => [],
		env_prefix  => $ENV_PREFIX,
	);
	my $val = $cfg->get('retries');
	is($val, '', 'CLI arg with empty value sets empty string');
};

subtest 'CLI - arg with = in value captures full value' => sub {
	local @ARGV = ("--${ENV_PREFIX}DSN=host=localhost;port=5432");

	my $cfg = Config::Abstraction->new(
		data        => { dsn => 'original' },
		config_dirs => [],
		env_prefix  => $ENV_PREFIX,
	);
	is($cfg->get('dsn'), 'host=localhost;port=5432', 'CLI value with embedded = preserved');
};

subtest 'CLI - non-matching prefix args ignored' => sub {
	local @ARGV = ('--OTHERAPP_KEY=value', '--notanoption', 'positional');

	my $cfg = Config::Abstraction->new(
		data        => { key => 'original' },
		config_dirs => [],
		env_prefix  => $ENV_PREFIX,
	);
	is($cfg->get('key'), 'original', 'non-matching CLI args ignored');
};

# ===========================================================================
# merge_defaults() edge cases
# ===========================================================================
subtest 'merge_defaults() - undef defaults arg returns config' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { key => 'value' },
		config_dirs => [],
	);
	my $result = $cfg->merge_defaults(defaults => undef);
	ok(defined($result), 'undef defaults returns config hashref');
};

subtest 'merge_defaults() - section that does not exist in config' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { key => 'value' },
		config_dirs => [],
	);
	my $result = $cfg->merge_defaults(
		defaults => { extra => 'kept' },
		section  => 'nosuchsection',
	);
	# Section absent - full config merged with defaults
	ok(defined($result), 'absent section does not crash');
	is($result->{extra}, 'kept', 'default preserved when section absent');
};

subtest 'merge_defaults() - empty defaults hash' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { key => 'value' },
		config_dirs => [],
	);
	my $result = $cfg->merge_defaults(defaults => {});
	ok(defined($result),         'empty defaults hash accepted');
	is($result->{key}, 'value',  'config key present in result');
};

subtest 'merge_defaults() - deep option with no global section' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { key => 'value' },
		config_dirs => [],
	);
	my $result;
	eval {
		$result = $cfg->merge_defaults(
			defaults => { extra => 'kept' },
			deep     => 1,
		);
	};
	ok(!$@, 'deep option with no global section does not crash');
	is($result->{extra}, 'kept', 'default preserved');
};

t/edge_cases.t  view on Meta::CPAN

	local %ENV = %ENV;
	$ENV{'XMARKER_CANARY'} = 'should_not_appear';

	my $cfg;
	_silenced(sub {
		eval {
			$cfg = Config::Abstraction->new(
				data        => { safe => 'value' },
				config_dirs => [],
				env_prefix  => '.',
			);
		};
	});
	# With the fix, '.' is literal so XMARKER_CANARY is not merged.
	# Confirm the canary value does not appear anywhere in the config.
	if(defined($cfg)) {
		my $all = $cfg->all() // {};
		my $dumped = join(' ', map { "$_ => $all->{$_}" } grep { defined $all->{$_} && !ref($all->{$_}) } keys %$all);
		unlike($dumped, qr/should_not_appear/, 'dot env_prefix does not ingest arbitrary env vars');
	} else {
		pass('constructor returned undef - no pollution possible');
	}
};

subtest 'SECURITY: env_prefix "APP+_" does not cause regex compile error' => sub {
	# Without \Q\E, /^APP+_/ is a valid but wrong regex (one or more Ps).
	# With \Q\E, /^\QAPP+_\E/ treats "+" literally.
	local %ENV = %ENV;
	$ENV{'APP+_KEY'} = 'plus_val';

	my $cfg;
	eval {
		$cfg = Config::Abstraction->new(
			data        => { key => 'original' },
			config_dirs => [],
			env_prefix  => 'APP+_',
		);
	};
	ok(!$@, 'env_prefix with "+" does not cause an unhandled regex compile error');
};

subtest 'SECURITY: env_prefix "(" does not cause fatal regex error' => sub {
	# Without \Q\E, /^APP(/ is an unbalanced group -> fatal Perl regex error.
	# With \Q\E, it is safe literal matching.
	local %ENV = %ENV;

	my $cfg;
	eval {
		$cfg = Config::Abstraction->new(
			data        => { key => 'original' },
			config_dirs => [],
			env_prefix  => 'APP(_',
		);
	};
	ok(!$@, 'env_prefix with unbalanced "(" does not die with regex error');
};

subtest 'SECURITY: env_prefix metachar does not match unintended CLI args' => sub {
	# Same quotemeta fix needed on the ARGV regex (line 775).
	# env_prefix = 'APP.' would make /^--APP.KEY=/ match --APP_KEY= (. = any char).
	local @ARGV = ('--APP_KEY=wrong', '--APPXKEY=also_wrong');

	my $cfg;
	eval {
		$cfg = Config::Abstraction->new(
			data        => { key => 'original' },
			config_dirs => [],
			env_prefix  => 'APP.',
		);
	};
	ok(!$@, 'ARGV regex with metachar env_prefix does not die');
	# With the fix, 'APP.' matches literally so APP_KEY and APPXKEY are skipped
	if(defined($cfg)) {
		isnt($cfg->get('key'), 'wrong',      'ARGV: unintended arg not merged via metachar prefix');
		isnt($cfg->get('key'), 'also_wrong', 'ARGV: second unintended arg not merged either');
	} else {
		pass('constructor returned undef - no injection possible');
	}
};

# ===========================================================================
# SECURITY: path traversal via config_file
# ---------------------------------------------------------------------------
# Config::Abstraction deliberately does NOT restrict which paths can be loaded
# (the module is a file loader by design), but traversal should not bypass the
# config_dirs constraint silently.  Document the behaviour.
# ===========================================================================

subtest 'SECURITY: path traversal in config_file loads relative path as-is' => sub {
	# The module does not sandbox paths; this test documents the design intent:
	# passing an absolute path to a nonexistent target returns undef (not a crash).
	my $cfg;
	_silenced(sub {
		eval {
			$cfg = Config::Abstraction->new(
				config_file => '/this/path/does/not/exist/ever.yaml',
				config_dirs => [''],
			);
		};
	});
	ok(!$@,        'nonexistent traversal path does not crash constructor');
	ok(!defined($cfg), 'nonexistent traversal path returns undef (no data loaded)');
};

# ===========================================================================
# Filesystem hostility
# ===========================================================================

subtest 'filesystem: /dev/null as config file produces no data' => sub {
	plan skip_all => '/dev/null not available on this platform' unless -e '/dev/null';

	my $cfg;
	eval {
		$cfg = Config::Abstraction->new(
			config_file => '/dev/null',
			config_dirs => [''],
			data        => { fallback => 'devnull_test' },
		);
	};
	ok(!$@, '/dev/null as config_file does not crash constructor');
	if(defined($cfg)) {

t/edge_cases.t  view on Meta::CPAN

		is($sources->{$key}{'value'}, $cfg->get($key),
			"explain_sources value for '$key' matches get()");
	}
};

subtest 'explain_sources() - config_path key is excluded from output' => sub {
	my $dir = tempdir(CLEANUP => 1);
	_write_file($dir, 'base.yaml', "x: 1\n");

	my $cfg = Config::Abstraction->new(config_dirs => [$dir]);
	ok(!exists $cfg->explain_sources()->{'config_path'},
		'config_path excluded from explain_sources output');
};

subtest 'explain_sources() - data source entry has correct type, label and value' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { mykey => 'myval' },
		config_dirs => [],
	);
	my $sources = $cfg->explain_sources();
	ok(exists $sources->{'mykey'}, 'mykey present in explain_sources');
	my @data_src = grep { $_->{'type'} eq 'data' } @{$sources->{'mykey'}{'sources'}};
	ok(scalar(@data_src) >= 1, 'at least one data-type source record for mykey');
	is($data_src[0]{'label'}, 'constructor data argument',
		'data source label is "constructor data argument"');
	is($data_src[0]{'value'}, 'myval', 'data source value is correct');
};

subtest 'explain_sources() - file source entry has type=file and path as label' => sub {
	my $dir = tempdir(CLEANUP => 1);
	_write_file($dir, 'base.yaml', "filekey: fileval\n");

	my $cfg    = Config::Abstraction->new(config_dirs => [$dir]);
	my $sources = $cfg->explain_sources();
	ok(exists $sources->{'filekey'}, 'filekey present in explain_sources');
	my @file_src = grep { $_->{'type'} eq 'file' } @{$sources->{'filekey'}{'sources'}};
	ok(scalar(@file_src) >= 1, 'file-type source present for filekey');
	like($file_src[0]{'label'}, qr/base\.yaml$/, 'file source label ends with base.yaml');
	is($file_src[0]{'value'}, 'fileval', 'file source value correct');
};

subtest 'explain_sources() - env source entry has type=env and var name as label' => sub {
	local %ENV = %ENV;
	$ENV{"${ENV_PREFIX}DATABASE__HOST"} = 'envhost';

	my $cfg = Config::Abstraction->new(
		data        => { database => { host => 'datahost' } },
		config_dirs => [],
		env_prefix  => $ENV_PREFIX,
	);
	my $sources = $cfg->explain_sources();
	my @env_src = grep { $_->{'type'} eq 'env' }
		@{$sources->{'database.host'}{'sources'}};
	ok(scalar(@env_src) >= 1, 'env-type source present for database.host');
	is($env_src[0]{'label'}, "${ENV_PREFIX}DATABASE__HOST",
		'env source label is the environment variable name');
	is($env_src[0]{'value'}, 'envhost', 'env source value matches the env var');
};

subtest 'explain_sources() - argv source entry has type=argv and arg string as label' => sub {
	local @ARGV = ("--${ENV_PREFIX}ARGKEY=argval");

	my $cfg = Config::Abstraction->new(
		data        => { argkey => 'original' },
		config_dirs => [],
		env_prefix  => $ENV_PREFIX,
	);
	my $sources = $cfg->explain_sources();
	my @argv_src = grep { $_->{'type'} eq 'argv' }
		@{$sources->{'argkey'}{'sources'}};
	ok(scalar(@argv_src) >= 1, 'argv-type source present for argkey');
	like($argv_src[0]{'label'}, qr/ARGKEY/, 'argv source label contains the key name');
	is($argv_src[0]{'value'}, 'argval', 'argv source value correct');
};

subtest 'explain_sources() - sources ordered lowest-to-highest precedence' => sub {
	# data < file < env - verify the ordering guarantee of the POD.
	local %ENV = %ENV;
	$ENV{"${ENV_PREFIX}MULTI__KEY"} = 'env_val';

	my $dir = tempdir(CLEANUP => 1);
	_write_file($dir, 'base.yaml', "multi:\n  key: file_val\n");

	my $cfg = Config::Abstraction->new(
		data        => { multi => { key => 'data_val' } },
		config_dirs => [$dir],
		env_prefix  => $ENV_PREFIX,
	);
	my $sources = $cfg->explain_sources();
	ok(exists $sources->{'multi.key'}, 'multi.key present in explain_sources');
	my @all_src = @{$sources->{'multi.key'}{'sources'}};
	ok(scalar(@all_src) >= 2, 'at least two source records for multi.key');
	is($all_src[0]{'type'}, 'data', 'first source is data (lowest precedence)');
	is($all_src[-1]{'type'}, 'env',  'last source is env (highest precedence)');
	is($sources->{'multi.key'}{'value'}, 'env_val',
		'final value is the env value (highest precedence wins)');
};

subtest 'explain_sources() - idempotent: repeated calls return equal structure' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { x => 1, y => 2 },
		config_dirs => [],
	);
	is_deeply($cfg->explain_sources(), $cfg->explain_sources(),
		'explain_sources() returns same structure on every call');
};

subtest 'explain_sources() - undef data value appears with undef in sources.value' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { nulkey => undef },
		config_dirs => [],
	);
	my $sources = $cfg->explain_sources();
	ok(exists $sources->{'nulkey'}, 'undef-valued key appears in explain_sources output');
	my @data_src = grep { $_->{'type'} eq 'data' }
		@{$sources->{'nulkey'}{'sources'}};
	ok(scalar(@data_src) >= 1, 'data source recorded for undef-valued key');
	ok(!defined($data_src[0]{'value'}),
		'source value is undef for a key the data arg set to undef');
};

t/edge_cases.t  view on Meta::CPAN


subtest 'prefer_file() - last file wins when multiple files contributed to same key' => sub {
	# base.yaml < local.yaml in precedence; prefer_file should return local.yaml value.
	my $dir = tempdir(CLEANUP => 1);
	_write_file($dir, 'base.yaml',  "multifile: base_val\n");
	_write_file($dir, 'local.yaml', "multifile: local_val\n");

	my $cfg = Config::Abstraction->new(config_dirs => [$dir]);
	is($cfg->prefer_file('multifile'), 'local_val',
		'prefer_file returns value from last-loaded file (local.yaml > base.yaml)');
};

# ===========================================================================
# prefer_data() - boundary conditions
# ===========================================================================

subtest 'prefer_data() returns data value even when a file overrode it' => sub {
	my $dir = tempdir(CLEANUP => 1);
	_write_file($dir, 'base.yaml', "host: from_file\n");

	my $cfg = Config::Abstraction->new(
		data        => { host => 'from_data' },
		config_dirs => [$dir],
	);
	is($cfg->get('host'),         'from_file', 'get() returns file-overridden value');
	is($cfg->prefer_data('host'), 'from_data', 'prefer_data bypasses file override');
};

subtest 'prefer_data() falls back to get() when data arg did not contribute to key' => sub {
	my $dir = tempdir(CLEANUP => 1);
	_write_file($dir, 'base.yaml', "file_only: from_file\n");

	my $cfg = Config::Abstraction->new(
		data        => { other => 'val' },
		config_dirs => [$dir],
	);
	is($cfg->prefer_data('file_only'), 'from_file',
		'prefer_data falls back to get() when data arg never set the key');
};

subtest 'prefer_data() with undef data value: $found gates return, not $val' => sub {
	# If data set key => undef, the source record exists (flat_data has the key).
	# So $found = 1 and $val = undef.  Returning "$found ? $val" is undef (correct),
	# NOT falling through to get() which might also return undef for other reasons.
	my $cfg = Config::Abstraction->new(
		data        => { nuldata => undef },
		config_dirs => [],
	);
	# Sanity: the key is visible in explain_sources, proving the source record exists.
	ok(exists $cfg->explain_sources()->{'nuldata'},
		'nuldata appears in explain_sources despite undef value');
	ok(!defined($cfg->prefer_data('nuldata')),
		'prefer_data returns undef for a key the data arg set to undef');
};

# ===========================================================================
# prefer_argv() - boundary conditions
# ===========================================================================

subtest 'prefer_argv() returns argv value when CLI arg contributed to key' => sub {
	local @ARGV = ("--${ENV_PREFIX}ARGVKEY=argv_val");

	my $cfg = Config::Abstraction->new(
		data        => { argvkey => 'data_val' },
		config_dirs => [],
		env_prefix  => $ENV_PREFIX,
	);
	is($cfg->prefer_argv('argvkey'), 'argv_val',
		'prefer_argv returns the argv-provided value');
};

subtest 'prefer_argv() falls back to get() when no CLI arg contributed' => sub {
	local @ARGV = ();

	my $cfg = Config::Abstraction->new(
		data        => { noargv => 'data_only' },
		config_dirs => [],
		env_prefix  => $ENV_PREFIX,
	);
	is($cfg->prefer_argv('noargv'), 'data_only',
		'prefer_argv falls back to get() when no CLI arg set the key');
};

subtest 'prefer_argv() equals get() since argv is the highest-precedence source' => sub {
	# When argv contributed, it already won the merge; get() and prefer_argv() agree.
	local @ARGV = ("--${ENV_PREFIX}TOPKEY=top_val");
	local %ENV  = %ENV;
	$ENV{"${ENV_PREFIX}DATABASE__TOPKEY"} = 'env_val';

	my $cfg = Config::Abstraction->new(
		data        => { topkey => 'data_val' },
		config_dirs => [],
		env_prefix  => $ENV_PREFIX,
	);
	is($cfg->get('topkey'),         'top_val', 'get() returns argv value (highest precedence)');
	is($cfg->prefer_argv('topkey'), 'top_val',
		'prefer_argv matches get() when argv is the winning source');
};

# ===========================================================================
# prefer_*() hostile inputs shared across all four methods
# ===========================================================================

subtest 'prefer_env() with undef key does not crash' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { x => 1 },
		config_dirs => [],
	);
	eval { $cfg->prefer_env(undef) };
	ok(!$@, 'prefer_env(undef) does not die');
};

subtest 'prefer_file() with undef key does not crash' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { x => 1 },
		config_dirs => [],
	);
	eval { $cfg->prefer_file(undef) };
	ok(!$@, 'prefer_file(undef) does not die');
};

subtest 'prefer_data() with empty string key does not crash' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { x => 1 },
		config_dirs => [],
	);
	eval { $cfg->prefer_data('') };
	ok(!$@, 'prefer_data("") does not die');
};

subtest 'prefer_argv() with very long key does not crash' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { x => 1 },
		config_dirs => [],
	);
	eval { $cfg->prefer_argv('k' x $LONG_STRING_LEN) };
	ok(!$@, 'prefer_argv with very long key does not die');
};

subtest 'all four prefer_*() methods agree when only data set the key' => sub {
	# When only the data arg contributed, all prefer_* fall back to get(),
	# and prefer_data returns the data value directly.  All four should agree.
	local @ARGV  = ();
	local %ENV   = %ENV;
	delete $ENV{$_} for grep { /^\Q$ENV_PREFIX\E/ } keys %ENV;

	my $cfg = Config::Abstraction->new(
		data        => { only_data => 'data_val' },
		config_dirs => [],
		env_prefix  => $ENV_PREFIX,
	);
	is($cfg->prefer_env('only_data'),  'data_val', 'prefer_env  returns data value');
	is($cfg->prefer_file('only_data'), 'data_val', 'prefer_file returns data value');
	is($cfg->prefer_data('only_data'), 'data_val', 'prefer_data returns data value');
	is($cfg->prefer_argv('only_data'), 'data_val', 'prefer_argv returns data value');
};

# ===========================================================================
# SECURITY: prefer_*() source injection via _source_records manipulation
# ---------------------------------------------------------------------------
# The _source_records structure is private.  Verify that mutating the hashref
# returned by all() (which is the live config ref) does NOT inject entries into
# the source records that then corrupt prefer_*() results.
# ===========================================================================

subtest 'SECURITY: mutating all() result does not corrupt prefer_data() output' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { safe => 'safe_val' },
		config_dirs => [],
	);
	# Inject a new key directly into the live config hashref (abuse of all())
	$cfg->all()->{'injected'} = 'evil';

	# injected key has no source record - prefer_data falls back to get()
	is($cfg->prefer_data('injected'), 'evil',
		'injected key falls back to get() (no source record exists for it)');

	# The safe key is unaffected
	is($cfg->prefer_data('safe'), 'safe_val', 'original safe key unaffected');
};

subtest 'SECURITY: explain_sources() does not expose keys injected via all() ref' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { real => 'real_val' },
		config_dirs => [],
	);
	$cfg->all()->{'injected_key'} = 'injected_val';

	my $sources = $cfg->explain_sources();
	# injected_key may appear in explain_sources (it's in the live config hash),
	# but it must have an empty sources list - it has no provenance record.
	if(exists $sources->{'injected_key'}) {
		my @src = @{$sources->{'injected_key'}{'sources'}};
		is(scalar(@src), 0,
			'injected key has no source records in explain_sources');
	} else {
		pass('injected key not exposed by explain_sources (acceptable if _flatten_keys skips it)');
	}
};

# ===========================================================================
# NO SOURCES PROVIDED
# ---------------------------------------------------------------------------
# The POD states: "Constructor returns undef (not a blessed object) when no
# configuration data is found."  Verify the contract when every source layer
# is empty or absent.
# ===========================================================================

subtest 'no sources: new() with no data and empty config_dirs returns undef' => sub {
	local @ARGV = ();
	local %ENV  = %ENV;
	delete $ENV{$_} for grep { /^\QAPP_\E/i } keys %ENV;

	my $cfg = Config::Abstraction->new(config_dirs => []);
	ok(!defined($cfg), 'new() with no data and config_dirs=[] returns undef');
};

subtest 'no sources: new() with data=>undef and empty config_dirs returns undef' => sub {
	local @ARGV = ();
	local %ENV  = %ENV;
	delete $ENV{$_} for grep { /^\QAPP_\E/i } keys %ENV;

	# data => undef is explicitly ignored (carp warning emitted); no keys loaded.
	my $cfg;
	_silenced(sub {
		$cfg = Config::Abstraction->new(data => undef, config_dirs => []);
	});
	ok(!defined($cfg), 'data=>undef with no files and no env/argv returns undef');
};

subtest 'no sources: new() returns undef even when config_dirs has nonexistent paths' => sub {
	local @ARGV = ();
	local %ENV  = %ENV;
	delete $ENV{$_} for grep { /^\QAPP_\E/i } keys %ENV;

	my $cfg = Config::Abstraction->new(
		config_dirs => ['/no/such/dir/a', '/no/such/dir/b'],
	);
	ok(!defined($cfg), 'all-nonexistent config_dirs with no data returns undef');
};

subtest 'no sources: explain_sources() is never called on a undef object (contract check)' => sub {
	# The POD says "Always check the return value before using the object."
	# This test documents that calling methods on a undef constructor result
	# would die with "Can't call method on undef".  We verify the return value
	# is indeed undef and do not call any method on it.
	local @ARGV = ();
	local %ENV  = %ENV;
	delete $ENV{$_} for grep { /^\QAPP_\E/i } keys %ENV;

	my $cfg = Config::Abstraction->new(config_dirs => []);
	ok(!defined($cfg), 'constructor returned undef as documented');
	dies_ok { $cfg->get('any') } 'calling get() on undef object dies (correct API contract)';
};

# ===========================================================================
# CONFLICTING SOURCES: EXPLICIT PRECEDENCE VERIFICATION
# ---------------------------------------------------------------------------
# The merge order is: data < file < env < argv.
# Each tier must override the tier below it for the same key.
# The tests use a common key 'database.host' and layer it progressively.
# ===========================================================================

Readonly::Scalar my $CONFLICT_PREFIX  => 'CONFLICT_';
Readonly::Scalar my $CONFLICT_DATA    => 'data_host';
Readonly::Scalar my $CONFLICT_FILE    => 'file_host';
Readonly::Scalar my $CONFLICT_ENV     => 'env_host';
Readonly::Scalar my $CONFLICT_ARGV    => 'argv_host';

subtest 'conflict: file overrides data for the same key (data < file)' => sub {
	my $dir = tempdir(CLEANUP => 1);
	_write_file($dir, 'base.yaml', "database:\n  host: $CONFLICT_FILE\n");

	my $cfg = Config::Abstraction->new(
		data        => { database => { host => $CONFLICT_DATA } },
		config_dirs => [$dir],
	);
	is($cfg->get('database.host'), $CONFLICT_FILE,
		"file value '$CONFLICT_FILE' overrides data value '$CONFLICT_DATA'");
	# Verify source provenance matches the winner
	my @file_src = grep { $_->{'type'} eq 'file' }
		@{$cfg->explain_sources()->{'database.host'}{'sources'}};
	ok(scalar(@file_src) >= 1, 'explain_sources records a file-type source for the winning value');
};

subtest 'conflict: env overrides file for the same key (file < env)' => sub {
	local %ENV = %ENV;
	$ENV{"${CONFLICT_PREFIX}DATABASE__HOST"} = $CONFLICT_ENV;

	my $dir = tempdir(CLEANUP => 1);
	_write_file($dir, 'base.yaml', "database:\n  host: $CONFLICT_FILE\n");

	my $cfg = Config::Abstraction->new(
		config_dirs => [$dir],
		env_prefix  => $CONFLICT_PREFIX,
	);
	is($cfg->get('database.host'), $CONFLICT_ENV,
		"env value '$CONFLICT_ENV' overrides file value '$CONFLICT_FILE'");
};

subtest 'conflict: argv overrides env for the same key (env < argv)' => sub {
	local %ENV  = %ENV;
	local @ARGV = ("--${CONFLICT_PREFIX}DATABASE__HOST=$CONFLICT_ARGV");
	$ENV{"${CONFLICT_PREFIX}DATABASE__HOST"} = $CONFLICT_ENV;

	my $cfg = Config::Abstraction->new(
		data        => { database => { host => $CONFLICT_DATA } },
		config_dirs => [],
		env_prefix  => $CONFLICT_PREFIX,
	);
	is($cfg->get('database.host'), $CONFLICT_ARGV,
		"argv value '$CONFLICT_ARGV' overrides env value '$CONFLICT_ENV'");
};

subtest 'conflict: full four-way stack - argv wins over data/file/env' => sub {
	local %ENV  = %ENV;
	local @ARGV = ("--${CONFLICT_PREFIX}DATABASE__HOST=$CONFLICT_ARGV");
	$ENV{"${CONFLICT_PREFIX}DATABASE__HOST"} = $CONFLICT_ENV;

	my $dir = tempdir(CLEANUP => 1);
	_write_file($dir, 'base.yaml', "database:\n  host: $CONFLICT_FILE\n");

	my $cfg = Config::Abstraction->new(
		data        => { database => { host => $CONFLICT_DATA } },
		config_dirs => [$dir],
		env_prefix  => $CONFLICT_PREFIX,
	);
	is($cfg->get('database.host'), $CONFLICT_ARGV,
		"argv is the definitive winner in a four-way data/file/env/argv conflict");

	# Confirm explain_sources records all four tiers and value matches argv
	my $explain = $cfg->explain_sources()->{'database.host'};
	is($explain->{'value'}, $CONFLICT_ARGV, 'explain_sources final value is argv value');
	my %by_type = map { $_->{'type'} => 1 } @{$explain->{'sources'}};
	ok($by_type{'data'}, 'data source recorded');
	ok($by_type{'file'}, 'file source recorded');
	ok($by_type{'env'},  'env source recorded');
	ok($by_type{'argv'}, 'argv source recorded');
};

subtest 'conflict: local.yaml overrides base.yaml for the same key (within file tier)' => sub {
	# Within the file tier, local.* files have higher precedence than base.*.
	my $dir = tempdir(CLEANUP => 1);
	_write_file($dir, 'base.yaml',  "tier: base_val\n");
	_write_file($dir, 'local.yaml', "tier: local_val\n");

	my $cfg = Config::Abstraction->new(config_dirs => [$dir]);
	is($cfg->get('tier'), 'local_val',
		'local.yaml overrides base.yaml for the same key');
};

subtest 'conflict: same key from two formats in file tier - YAML wins over INI' => sub {
	# The load order is base.yaml, base.yml, base.json, base.xml, base.ini, …
	# base.ini is loaded after base.yaml so it has higher file-tier precedence.
	my $dir = tempdir(CLEANUP => 1);
	_write_file($dir, 'base.yaml', "[database]\nhost = yaml_host\n");
	_write_file($dir, 'base.ini',  "[database]\nhost = ini_host\n");

	my $cfg;
	_silenced(sub {
		$cfg = Config::Abstraction->new(config_dirs => [$dir]);
	});
	ok(defined($cfg), 'both base.yaml and base.ini loaded without crash');
	# base.ini is loaded after base.yaml, so INI value wins within the file tier
	is($cfg->get('database.host'), 'ini_host',
		'base.ini (loaded later) overrides base.yaml within the file tier');
};

# ===========================================================================
# REGRESSION TESTS
# ---------------------------------------------------------------------------
# Named regression tests for bugs found in the wild.  Each test is labelled
# with the ticket or commit that introduced the fix so that git-blame can
# trace lineage and the test suite immediately identifies a reintroduced bug.
# ===========================================================================

subtest 'REGRESSION merge_defaults mutation: global section preserved across calls (fix: 0.40)' => sub {

t/edge_cases.t  view on Meta::CPAN

# flatten mode: AUTOLOAD, explain_sources, and dotted key get() in flat mode
# ===========================================================================

subtest 'flatten: get() with dotted key traverses the flat hash correctly' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { db => { user => 'alice', pass => 'secret' } },
		config_dirs => [],
		flatten     => 1,
	);
	# In flatten mode, the config is stored as { 'db.user' => 'alice', 'db.pass' => 'secret' }
	is($cfg->get('db.user'), 'alice',  'flatten: dotted key lookup for db.user');
	is($cfg->get('db.pass'), 'secret', 'flatten: dotted key lookup for db.pass');
	ok(!defined($cfg->get('db')),      'flatten: bare parent key returns undef in flat hash');
};

subtest 'flatten: AUTOLOAD translates sep_char to dot form for flat hash lookup' => sub {
	# When sep_char='_' and flatten=1, AUTOLOAD translates db_user -> db.user before lookup.
	my $cfg = Config::Abstraction->new(
		data        => { db => { user => 'alice' } },
		config_dirs => [],
		flatten     => 1,
		sep_char    => $SEP_US,
	);
	my $val;
	lives_ok { $val = $cfg->db_user() }
		'AUTOLOAD in flatten mode with sep_char=_ does not crash';
	is($val, 'alice', 'AUTOLOAD in flatten mode returns correct value');
};

subtest 'flatten: explain_sources() works correctly in flat mode' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { db => { user => 'alice' } },
		config_dirs => [],
		flatten     => 1,
	);
	my $es;
	lives_ok { $es = $cfg->explain_sources() }
		'explain_sources() does not crash in flatten mode';
	ok(ref($es) eq 'HASH', 'explain_sources() returns hashref in flatten mode');
	ok(exists $es->{'db.user'}, 'dotted key present in explain_sources in flat mode');
};

subtest 'flatten: exists() returns 1 for a flat dotted key' => sub {
	my $cfg = Config::Abstraction->new(
		data        => { a => { b => 'val' } },
		config_dirs => [],
		flatten     => 1,
	);
	is($cfg->exists('a.b'), 1, 'flatten: exists() returns 1 for present dotted key');
	is($cfg->exists('a'),   0, 'flatten: exists() returns 0 for bare parent in flat hash');
};

# ===========================================================================
# env_prefix edge cases
# ===========================================================================

subtest 'env_prefix: empty string does not crash constructor' => sub {
	# An empty prefix means the regex pattern matches all env vars.
	# This is an extreme use case; we verify it does not crash.
	local %ENV = %ENV;
	local @ARGV = ();

	my $cfg;
	eval {
		$cfg = Config::Abstraction->new(
			data        => { specific => 'value' },
			config_dirs => [],
			env_prefix  => '',
		);
	};
	ok(!$@, 'empty string env_prefix does not die in constructor');
};

subtest 'env_prefix: very long prefix string does not crash' => sub {
	my $long_prefix = 'A' x 1000 . '_';
	local %ENV = %ENV;
	local @ARGV = ();

	my $cfg;
	eval {
		$cfg = Config::Abstraction->new(
			data        => { x => 1 },
			config_dirs => [],
			env_prefix  => $long_prefix,
		);
	};
	ok(!$@, 'very long env_prefix does not crash constructor');
};

# ===========================================================================
# CONFIG_DIR environment variable
# ===========================================================================

subtest 'CONFIG_DIR env var overrides default config directory discovery' => sub {
	my $dir = tempdir(CLEANUP => 1);
	_write_file($dir, 'base.yaml', "config_dir_env_canary: found_via_CONFIG_DIR\n");

	local %ENV = (%ENV, CONFIG_DIR => $dir);
	my $cfg;
	eval {
		$cfg = Config::Abstraction->new();
	};
	ok(!$@, 'CONFIG_DIR env var does not crash constructor');
	if(defined($cfg)) {
		is($cfg->get('config_dir_env_canary'), 'found_via_CONFIG_DIR',
			'config loaded from directory specified by CONFIG_DIR env var');
	} else {
		pass('constructor returned undef - CONFIG_DIR may have been overridden by other local config');
	}
};

# ===========================================================================
# INI format: multi-section and global-key edge cases
# ===========================================================================

subtest 'INI: multi-section file: each section becomes a nested hash' => sub {
	my $dir = tempdir(CLEANUP => 1);
	_write_file($dir, 'base.ini',
		"[section1]\nkey1 = val1\n\n[section2]\nkey2 = val2\nshared = s2_val\n");

	my $cfg = Config::Abstraction->new(
		data        => {},
		config_dirs => [$dir],
	);
	ok(defined($cfg), 'multi-section INI file loaded successfully');
	is($cfg->get('section1.key1'), 'val1', 'section1.key1 accessible via dotted notation');
	is($cfg->get('section2.key2'), 'val2', 'section2.key2 accessible via dotted notation');
	is($cfg->get('section2.shared'), 's2_val', 'section2.shared accessible');
};

subtest 'INI: file with no sections (key=value only) does not crash' => sub {
	my $dir = tempdir(CLEANUP => 1);
	_write_file($dir, 'base.ini', "global_key = global_val\n");

	my $cfg;
	_silenced(sub {
		eval {



( run in 1.661 second using v1.01-cache-2.11-cpan-364913b4093 )