Files
michaelschiemer/tests/Unit/Framework/Config/EnvironmentTest.php
Michael Schiemer a1242f776e refactor(config): add EnumResolver for cache-backed enum resolution and extend DockerSecretsResolver with caching
- Introduce `EnumResolver` to centralize and cache enum value conversions.
- Enhance `DockerSecretsResolver` with result caching to avoid redundant file reads and improve performance.
- Update `Environment` to integrate `EnumResolver` for enriched enum resolution support and improved maintainability.
- Adjust unit tests to validate caching mechanisms and error handling improvements.
2025-11-03 23:47:08 +01:00

818 lines
27 KiB
PHP

<?php
declare(strict_types=1);
use App\Framework\Config\DockerSecretsResolver;
use App\Framework\Config\EnvKey;
use App\Framework\Config\Environment;
use App\Framework\Exception\Config\EnvironmentVariableNotFoundException;
describe('Environment', function () {
beforeEach(function () {
// Create test environment variables
$this->testVariables = [
'APP_NAME' => 'TestApp',
'APP_ENV' => 'development',
'APP_DEBUG' => 'true',
'DB_HOST' => 'localhost',
'DB_PORT' => '3306',
'DB_NAME' => 'testdb',
'CACHE_TTL' => '3600',
'API_TIMEOUT' => '30.5',
'FEATURE_FLAGS' => 'feature1,feature2,feature3',
'EMPTY_STRING' => '',
'NULL_VALUE' => 'null',
'FALSE_VALUE' => 'false',
'ZERO_VALUE' => '0',
];
$this->dockerSecretsResolver = new DockerSecretsResolver();
$this->environment = new Environment($this->testVariables, $this->dockerSecretsResolver);
});
describe('get()', function () {
it('returns string value for existing key', function () {
$value = $this->environment->get('APP_NAME');
expect($value)->toBe('TestApp');
});
it('returns null for non-existent key', function () {
$value = $this->environment->get('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->get('NON_EXISTENT', 'default_value');
expect($value)->toBe('default_value');
});
it('returns empty string when value is empty', function () {
$value = $this->environment->get('EMPTY_STRING');
expect($value)->toBe('');
});
it('returns string "null" for null value', function () {
$value = $this->environment->get('NULL_VALUE');
expect($value)->toBe('null');
});
it('accepts EnvKey enum', function () {
$value = $this->environment->get(EnvKey::APP_NAME);
expect($value)->toBe('TestApp');
});
});
describe('getInt()', function () {
it('returns integer for numeric string', function () {
$value = $this->environment->getInt('DB_PORT');
expect($value)->toBe(3306);
expect($value)->toBeInt();
});
it('returns null for non-existent key', function () {
$value = $this->environment->getInt('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getInt('NON_EXISTENT', 9999);
expect($value)->toBe(9999);
});
it('returns zero for "0" string', function () {
$value = $this->environment->getInt('ZERO_VALUE');
expect($value)->toBe(0);
expect($value)->toBeInt();
});
it('accepts EnvKey enum', function () {
$value = $this->environment->getInt(EnvKey::DB_PORT);
expect($value)->toBe(3306);
});
it('handles negative integers', function () {
$env = new Environment(['NEGATIVE' => '-42'], $this->dockerSecretsResolver);
$value = $env->getInt('NEGATIVE');
expect($value)->toBe(-42);
});
});
describe('getBool()', function () {
it('returns true for "true" string', function () {
$value = $this->environment->getBool('APP_DEBUG');
expect($value)->toBeTrue();
});
it('returns false for "false" string', function () {
$value = $this->environment->getBool('FALSE_VALUE');
expect($value)->toBeFalse();
});
it('returns null for non-existent key', function () {
$value = $this->environment->getBool('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getBool('NON_EXISTENT', true);
expect($value)->toBeTrue();
});
it('handles case-insensitive true values', function () {
$env = new Environment([
'BOOL1' => 'true',
'BOOL2' => 'True',
'BOOL3' => 'TRUE',
'BOOL4' => '1',
], $this->dockerSecretsResolver);
expect($env->getBool('BOOL1'))->toBeTrue();
expect($env->getBool('BOOL2'))->toBeTrue();
expect($env->getBool('BOOL3'))->toBeTrue();
expect($env->getBool('BOOL4'))->toBeTrue();
});
it('handles case-insensitive false values', function () {
$env = new Environment([
'BOOL1' => 'false',
'BOOL2' => 'False',
'BOOL3' => 'FALSE',
'BOOL4' => '0',
'BOOL5' => '',
], $this->dockerSecretsResolver);
expect($env->getBool('BOOL1'))->toBeFalse();
expect($env->getBool('BOOL2'))->toBeFalse();
expect($env->getBool('BOOL3'))->toBeFalse();
expect($env->getBool('BOOL4'))->toBeFalse();
expect($env->getBool('BOOL5'))->toBeFalse();
});
it('accepts EnvKey enum', function () {
$value = $this->environment->getBool(EnvKey::APP_DEBUG);
expect($value)->toBeTrue();
});
});
describe('getFloat()', function () {
it('returns float for decimal string', function () {
$value = $this->environment->getFloat('API_TIMEOUT');
expect($value)->toBe(30.5);
expect($value)->toBeFloat();
});
it('returns null for non-existent key', function () {
$value = $this->environment->getFloat('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getFloat('NON_EXISTENT', 99.99);
expect($value)->toBe(99.99);
});
it('converts integer string to float', function () {
$value = $this->environment->getFloat('DB_PORT');
expect($value)->toBe(3306.0);
expect($value)->toBeFloat();
});
it('handles negative floats', function () {
$env = new Environment(['NEGATIVE' => '-42.5'], $this->dockerSecretsResolver);
$value = $env->getFloat('NEGATIVE');
expect($value)->toBe(-42.5);
});
it('accepts EnvKey enum', function () {
$value = $this->environment->getFloat(EnvKey::API_TIMEOUT);
expect($value)->toBe(30.5);
});
});
describe('getArray()', function () {
it('returns array from comma-separated string', function () {
$value = $this->environment->getArray('FEATURE_FLAGS');
expect($value)->toBe(['feature1', 'feature2', 'feature3']);
expect($value)->toBeArray();
});
it('returns null for non-existent key', function () {
$value = $this->environment->getArray('NON_EXISTENT');
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getArray('NON_EXISTENT', ['default1', 'default2']);
expect($value)->toBe(['default1', 'default2']);
});
it('returns single-element array for non-comma value', function () {
$value = $this->environment->getArray('APP_NAME');
expect($value)->toBe(['TestApp']);
});
it('trims whitespace from array elements', function () {
$env = new Environment([
'WHITESPACE_ARRAY' => 'value1 , value2 , value3 ',
], $this->dockerSecretsResolver);
$value = $env->getArray('WHITESPACE_ARRAY');
expect($value)->toBe(['value1', 'value2', 'value3']);
});
it('returns empty array for empty string', function () {
$value = $this->environment->getArray('EMPTY_STRING');
expect($value)->toBe(['']);
});
it('accepts custom separator', function () {
$env = new Environment([
'PIPE_SEPARATED' => 'value1|value2|value3',
], $this->dockerSecretsResolver);
$value = $env->getArray('PIPE_SEPARATED', separator: '|');
expect($value)->toBe(['value1', 'value2', 'value3']);
});
it('accepts EnvKey enum', function () {
$value = $this->environment->getArray(EnvKey::FEATURE_FLAGS);
expect($value)->toBe(['feature1', 'feature2', 'feature3']);
});
});
describe('getEnum()', function () {
it('returns enum value for valid string', function () {
$value = $this->environment->getEnum('APP_ENV', AppEnvironment::class);
expect($value)->toBeInstanceOf(AppEnvironment::class);
expect($value)->toBe(AppEnvironment::DEVELOPMENT);
});
it('returns null for non-existent key', function () {
$value = $this->environment->getEnum('NON_EXISTENT', AppEnvironment::class);
expect($value)->toBeNull();
});
it('returns default value for non-existent key', function () {
$value = $this->environment->getEnum(
'NON_EXISTENT',
AppEnvironment::class,
AppEnvironment::PRODUCTION
);
expect($value)->toBe(AppEnvironment::PRODUCTION);
});
it('handles case-insensitive enum matching', function () {
$env = new Environment([
'ENV1' => 'development',
'ENV2' => 'Development',
'ENV3' => 'DEVELOPMENT',
], $this->dockerSecretsResolver);
expect($env->getEnum('ENV1', AppEnvironment::class))->toBe(AppEnvironment::DEVELOPMENT);
expect($env->getEnum('ENV2', AppEnvironment::class))->toBe(AppEnvironment::DEVELOPMENT);
expect($env->getEnum('ENV3', AppEnvironment::class))->toBe(AppEnvironment::DEVELOPMENT);
});
it('accepts EnvKey enum for key parameter', function () {
$value = $this->environment->getEnum(EnvKey::APP_ENV, AppEnvironment::class);
expect($value)->toBe(AppEnvironment::DEVELOPMENT);
});
it('handles backed enum with integer value', function () {
$env = new Environment(['STATUS' => '1'], $this->dockerSecretsResolver);
$value = $env->getEnum('STATUS', TestIntEnum::class);
expect($value)->toBe(TestIntEnum::ACTIVE);
});
});
describe('require()', function () {
it('returns value for existing key', function () {
$value = $this->environment->require('APP_NAME');
expect($value)->toBe('TestApp');
});
it('throws exception for non-existent key', function () {
$this->environment->require('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->require(EnvKey::APP_NAME);
expect($value)->toBe('TestApp');
});
it('throws exception with helpful message', function () {
try {
$this->environment->require('MISSING_VARIABLE');
$this->fail('Expected EnvironmentVariableNotFoundException');
} catch (EnvironmentVariableNotFoundException $e) {
expect($e->getMessage())->toContain('MISSING_VARIABLE');
expect($e->getMessage())->toContain('required');
}
});
});
describe('requireInt()', function () {
it('returns integer for existing numeric key', function () {
$value = $this->environment->requireInt('DB_PORT');
expect($value)->toBe(3306);
expect($value)->toBeInt();
});
it('throws exception for non-existent key', function () {
$this->environment->requireInt('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->requireInt(EnvKey::DB_PORT);
expect($value)->toBe(3306);
});
});
describe('requireBool()', function () {
it('returns boolean for existing boolean key', function () {
$value = $this->environment->requireBool('APP_DEBUG');
expect($value)->toBeTrue();
});
it('throws exception for non-existent key', function () {
$this->environment->requireBool('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->requireBool(EnvKey::APP_DEBUG);
expect($value)->toBeTrue();
});
});
describe('requireFloat()', function () {
it('returns float for existing numeric key', function () {
$value = $this->environment->requireFloat('API_TIMEOUT');
expect($value)->toBe(30.5);
expect($value)->toBeFloat();
});
it('throws exception for non-existent key', function () {
$this->environment->requireFloat('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->requireFloat(EnvKey::API_TIMEOUT);
expect($value)->toBe(30.5);
});
});
describe('requireArray()', function () {
it('returns array for existing comma-separated key', function () {
$value = $this->environment->requireArray('FEATURE_FLAGS');
expect($value)->toBe(['feature1', 'feature2', 'feature3']);
expect($value)->toBeArray();
});
it('throws exception for non-existent key', function () {
$this->environment->requireArray('NON_EXISTENT');
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum', function () {
$value = $this->environment->requireArray(EnvKey::FEATURE_FLAGS);
expect($value)->toBe(['feature1', 'feature2', 'feature3']);
});
it('accepts custom separator', function () {
$env = new Environment([
'PIPE_SEPARATED' => 'value1|value2|value3',
], $this->dockerSecretsResolver);
$value = $env->requireArray('PIPE_SEPARATED', separator: '|');
expect($value)->toBe(['value1', 'value2', 'value3']);
});
});
describe('requireEnum()', function () {
it('returns enum value for valid string', function () {
$value = $this->environment->requireEnum('APP_ENV', AppEnvironment::class);
expect($value)->toBeInstanceOf(AppEnvironment::class);
expect($value)->toBe(AppEnvironment::DEVELOPMENT);
});
it('throws exception for non-existent key', function () {
$this->environment->requireEnum('NON_EXISTENT', AppEnvironment::class);
})->throws(EnvironmentVariableNotFoundException::class);
it('accepts EnvKey enum for key parameter', function () {
$value = $this->environment->requireEnum(EnvKey::APP_ENV, AppEnvironment::class);
expect($value)->toBe(AppEnvironment::DEVELOPMENT);
});
});
describe('has()', function () {
it('returns true for existing key', function () {
$exists = $this->environment->has('APP_NAME');
expect($exists)->toBeTrue();
});
it('returns false for non-existent key', function () {
$exists = $this->environment->has('NON_EXISTENT');
expect($exists)->toBeFalse();
});
it('returns true for empty string value', function () {
$exists = $this->environment->has('EMPTY_STRING');
expect($exists)->toBeTrue();
});
it('accepts EnvKey enum', function () {
$exists = $this->environment->has(EnvKey::APP_NAME);
expect($exists)->toBeTrue();
});
});
describe('all()', function () {
it('returns all environment variables', function () {
$allVars = $this->environment->all();
expect($allVars)->toBe($this->testVariables);
expect($allVars)->toBeArray();
});
it('returns empty array for empty environment', function () {
$emptyEnv = new Environment([], $this->dockerSecretsResolver);
$allVars = $emptyEnv->all();
expect($allVars)->toBe([]);
});
});
describe('Docker Secrets Integration', function () {
it('resolves Docker secret when file exists', function () {
// Create temporary Docker secret file
$secretPath = '/tmp/docker_secret_test';
file_put_contents($secretPath, 'secret_value_from_file');
$env = new Environment([
'DB_PASSWORD_FILE' => $secretPath,
], $this->dockerSecretsResolver);
$value = $env->get('DB_PASSWORD');
expect($value)->toBe('secret_value_from_file');
// Cleanup
unlink($secretPath);
});
it('returns null when Docker secret file does not exist', function () {
$env = new Environment([
'DB_PASSWORD_FILE' => '/nonexistent/path/to/secret',
], $this->dockerSecretsResolver);
$value = $env->get('DB_PASSWORD');
expect($value)->toBeNull();
});
it('prioritizes direct variable over Docker secret', function () {
// Create temporary Docker secret file
$secretPath = '/tmp/docker_secret_test';
file_put_contents($secretPath, 'secret_from_file');
$env = new Environment([
'DB_PASSWORD' => 'direct_value',
'DB_PASSWORD_FILE' => $secretPath,
], $this->dockerSecretsResolver);
$value = $env->get('DB_PASSWORD');
// Direct variable should win
expect($value)->toBe('direct_value');
// Cleanup
unlink($secretPath);
});
it('trims whitespace from Docker secret content', function () {
// Create temporary Docker secret file with whitespace
$secretPath = '/tmp/docker_secret_test';
file_put_contents($secretPath, " secret_value_with_whitespace \n");
$env = new Environment([
'API_KEY_FILE' => $secretPath,
], $this->dockerSecretsResolver);
$value = $env->get('API_KEY');
expect($value)->toBe('secret_value_with_whitespace');
// Cleanup
unlink($secretPath);
});
it('handles multiple Docker secrets', function () {
// Create multiple secret files
$dbPasswordPath = '/tmp/db_password_secret';
$apiKeyPath = '/tmp/api_key_secret';
file_put_contents($dbPasswordPath, 'db_secret_123');
file_put_contents($apiKeyPath, 'api_secret_456');
$env = new Environment([
'DB_PASSWORD_FILE' => $dbPasswordPath,
'API_KEY_FILE' => $apiKeyPath,
], $this->dockerSecretsResolver);
expect($env->get('DB_PASSWORD'))->toBe('db_secret_123');
expect($env->get('API_KEY'))->toBe('api_secret_456');
// Cleanup
unlink($dbPasswordPath);
unlink($apiKeyPath);
});
it('uses Docker secret when variable is set to empty string', function () {
// Create temporary Docker secret file
$secretPath = '/tmp/docker_secret_test';
file_put_contents($secretPath, 'secret_from_file');
$env = new Environment([
'REDIS_PASSWORD' => '', // Empty string
'REDIS_PASSWORD_FILE' => $secretPath,
], $this->dockerSecretsResolver);
$value = $env->get('REDIS_PASSWORD');
// Docker Secret should override empty string
expect($value)->toBe('secret_from_file');
// Cleanup
unlink($secretPath);
});
it('returns empty string when variable is empty and no Docker secret exists', function () {
$env = new Environment([
'REDIS_PASSWORD' => '', // Empty string, no FILE
], $this->dockerSecretsResolver);
$value = $env->get('REDIS_PASSWORD');
// Empty string should be returned when explicitly set
expect($value)->toBe('');
});
it('caches Docker secret resolution results', function () {
// Create temporary Docker secret file
$secretPath = '/tmp/docker_secret_test_caching';
file_put_contents($secretPath, 'cached_secret_value');
$env = new Environment([
'DB_PASSWORD_FILE' => $secretPath,
], $this->dockerSecretsResolver);
// First call - should read from file
$value1 = $env->get('DB_PASSWORD');
expect($value1)->toBe('cached_secret_value');
// Modify file content (should not affect cached value)
file_put_contents($secretPath, 'modified_secret_value');
// Second call - should use cache, not re-read file
$value2 = $env->get('DB_PASSWORD');
expect($value2)->toBe('cached_secret_value');
// Cleanup
unlink($secretPath);
});
it('caches enum conversion results', function () {
// Create a test enum
if (!enum_exists('TestEnum')) {
eval('enum TestEnum: string { case FOO = "foo"; case BAR = "bar"; }');
}
$env = new Environment([
'APP_ENV' => 'foo',
], $this->dockerSecretsResolver);
$default = TestEnum::BAR;
// First call - should convert
$enum1 = $env->getEnum('APP_ENV', TestEnum::class, $default);
expect($enum1)->toBe(TestEnum::FOO);
// Second call to same instance should use cache
$enum2 = $env->getEnum('APP_ENV', TestEnum::class, $default);
expect($enum2)->toBe(TestEnum::FOO);
expect($enum1)->toBe($enum2);
// Different enum class should have different cache entry
if (!enum_exists('AnotherTestEnum')) {
eval('enum AnotherTestEnum: string { case FOO = "foo"; case BAR = "bar"; }');
}
$enum3 = $env->getEnum('APP_ENV', AnotherTestEnum::class, AnotherTestEnum::BAR);
expect($enum3)->toBe(AnotherTestEnum::FOO);
});
});
describe('Edge Cases', function () {
it('handles keys with underscores', function () {
$env = new Environment([
'SOME_LONG_KEY_NAME' => 'value',
], $this->dockerSecretsResolver);
$value = $env->get('SOME_LONG_KEY_NAME');
expect($value)->toBe('value');
});
it('handles keys with numbers', function () {
$env = new Environment([
'VAR_123' => 'value',
], $this->dockerSecretsResolver);
$value = $env->get('VAR_123');
expect($value)->toBe('value');
});
it('handles very long values', function () {
$longValue = str_repeat('a', 10000);
$env = new Environment([
'LONG_VALUE' => $longValue,
], $this->dockerSecretsResolver);
$value = $env->get('LONG_VALUE');
expect($value)->toBe($longValue);
});
it('handles special characters in values', function () {
$env = new Environment([
'SPECIAL' => 'value!@#$%^&*()_+-=[]{}|;:\'",.<>?/`~',
], $this->dockerSecretsResolver);
$value = $env->get('SPECIAL');
expect($value)->toBe('value!@#$%^&*()_+-=[]{}|;:\'",.<>?/`~');
});
it('handles unicode characters', function () {
$env = new Environment([
'UNICODE' => 'Hello 世界 🌍',
], $this->dockerSecretsResolver);
$value = $env->get('UNICODE');
expect($value)->toBe('Hello 世界 🌍');
});
it('handles JSON strings as values', function () {
$jsonString = '{"key":"value","nested":{"foo":"bar"}}';
$env = new Environment([
'JSON_CONFIG' => $jsonString,
], $this->dockerSecretsResolver);
$value = $env->get('JSON_CONFIG');
expect($value)->toBe($jsonString);
expect(json_decode($value, true))->toBe([
'key' => 'value',
'nested' => ['foo' => 'bar'],
]);
});
it('handles URL strings as values', function () {
$env = new Environment([
'DATABASE_URL' => 'mysql://user:pass@localhost:3306/dbname',
], $this->dockerSecretsResolver);
$value = $env->get('DATABASE_URL');
expect($value)->toBe('mysql://user:pass@localhost:3306/dbname');
});
it('handles base64-encoded values', function () {
$base64Value = base64_encode('secret_data');
$env = new Environment([
'ENCODED_SECRET' => $base64Value,
], $this->dockerSecretsResolver);
$value = $env->get('ENCODED_SECRET');
expect($value)->toBe($base64Value);
expect(base64_decode($value))->toBe('secret_data');
});
});
describe('Type Coercion Edge Cases', function () {
it('handles non-numeric string for getInt', function () {
$env = new Environment([
'NOT_A_NUMBER' => 'abc',
], $this->dockerSecretsResolver);
$value = $env->getInt('NOT_A_NUMBER');
expect($value)->toBe(0); // PHP's (int) cast behavior
});
it('handles non-boolean string for getBool', function () {
$env = new Environment([
'NOT_A_BOOL' => 'maybe',
], $this->dockerSecretsResolver);
$value = $env->getBool('NOT_A_BOOL');
expect($value)->toBeFalse(); // Non-empty string that's not "true" or "1"
});
it('handles scientific notation for getFloat', function () {
$env = new Environment([
'SCIENTIFIC' => '1.5e3',
], $this->dockerSecretsResolver);
$value = $env->getFloat('SCIENTIFIC');
expect($value)->toBe(1500.0);
});
it('handles hexadecimal strings for getInt', function () {
$env = new Environment([
'HEX_VALUE' => '0xFF',
], $this->dockerSecretsResolver);
$value = $env->getInt('HEX_VALUE');
expect($value)->toBe(0); // String "0xFF" casts to 0
});
});
});
// Test enums for getEnum() testing
enum AppEnvironment: string
{
case DEVELOPMENT = 'development';
case STAGING = 'staging';
case PRODUCTION = 'production';
}
enum TestIntEnum: int
{
case INACTIVE = 0;
case ACTIVE = 1;
case PENDING = 2;
}