feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready
This commit is contained in:
@@ -0,0 +1,557 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Config\EncryptedEnvLoader;
|
||||
use App\Framework\Config\EnvFileParser;
|
||||
use App\Framework\Config\Environment;
|
||||
use App\Framework\Config\EnvKey;
|
||||
use App\Framework\Encryption\EncryptionFactory;
|
||||
use App\Framework\Filesystem\ValueObjects\FilePath;
|
||||
use App\Framework\Random\SecureRandomGenerator;
|
||||
|
||||
/**
|
||||
* Integration tests for end-to-end environment loading flow
|
||||
*
|
||||
* These tests verify the entire environment loading system works correctly:
|
||||
* - EncryptedEnvLoader loading from .env files
|
||||
* - Environment object created with parsed variables
|
||||
* - DockerSecretsResolver integration in real scenarios
|
||||
* - EnvFileParser correctly parsing various .env formats
|
||||
* - Two-pass loading with encryption support
|
||||
* - Production vs Development priority handling
|
||||
* - PHP-FPM getenv() priority fix verification
|
||||
*/
|
||||
describe('Environment Loading Integration', function () {
|
||||
beforeEach(function () {
|
||||
$this->testDir = '/home/michael/dev/michaelschiemer/tests/tmp';
|
||||
|
||||
// Create test directory if it doesn't exist
|
||||
if (!is_dir($this->testDir)) {
|
||||
mkdir($this->testDir, 0777, true);
|
||||
}
|
||||
|
||||
// Initialize services
|
||||
$this->parser = new EnvFileParser();
|
||||
$this->encryptionFactory = new EncryptionFactory(new SecureRandomGenerator());
|
||||
$this->loader = new EncryptedEnvLoader($this->encryptionFactory, $this->parser);
|
||||
|
||||
// Store original $_ENV and getenv() for restoration
|
||||
$this->originalEnv = $_ENV;
|
||||
$this->originalGetenv = getenv();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Restore original environment
|
||||
$_ENV = $this->originalEnv;
|
||||
|
||||
// Clear test directory
|
||||
if (is_dir($this->testDir)) {
|
||||
$files = glob($this->testDir . '/*');
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file)) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('Basic .env File Loading', function () {
|
||||
it('loads environment from simple .env file', function () {
|
||||
$envContent = <<<ENV
|
||||
APP_NAME=TestApp
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
ENV;
|
||||
|
||||
file_put_contents($this->testDir . '/.env', $envContent);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
expect($env->get('APP_NAME'))->toBe('TestApp');
|
||||
expect($env->get('APP_ENV'))->toBe('production');
|
||||
expect($env->getBool('APP_DEBUG'))->toBeFalse();
|
||||
expect($env->get('DB_HOST'))->toBe('localhost');
|
||||
expect($env->getInt('DB_PORT'))->toBe(3306);
|
||||
});
|
||||
|
||||
it('handles quoted values correctly', function () {
|
||||
$envContent = <<<ENV
|
||||
SINGLE_QUOTED='single quoted value'
|
||||
DOUBLE_QUOTED="double quoted value"
|
||||
UNQUOTED=unquoted value
|
||||
ENV;
|
||||
|
||||
file_put_contents($this->testDir . '/.env', $envContent);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
expect($env->get('SINGLE_QUOTED'))->toBe('single quoted value');
|
||||
expect($env->get('DOUBLE_QUOTED'))->toBe('double quoted value');
|
||||
expect($env->get('UNQUOTED'))->toBe('unquoted value');
|
||||
});
|
||||
|
||||
it('handles type casting correctly', function () {
|
||||
$envContent = <<<ENV
|
||||
BOOL_TRUE=true
|
||||
BOOL_FALSE=false
|
||||
INT_VALUE=123
|
||||
FLOAT_VALUE=123.45
|
||||
NULL_VALUE=null
|
||||
ARRAY_VALUE=item1,item2,item3
|
||||
ENV;
|
||||
|
||||
file_put_contents($this->testDir . '/.env', $envContent);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
expect($env->getBool('BOOL_TRUE'))->toBeTrue();
|
||||
expect($env->getBool('BOOL_FALSE'))->toBeFalse();
|
||||
expect($env->getInt('INT_VALUE'))->toBe(123);
|
||||
expect($env->getFloat('FLOAT_VALUE'))->toBe(123.45);
|
||||
expect($env->get('NULL_VALUE'))->toBeNull();
|
||||
expect($env->getArray('ARRAY_VALUE'))->toBe(['item1', 'item2', 'item3']);
|
||||
});
|
||||
|
||||
it('handles comments and empty lines', function () {
|
||||
$envContent = <<<ENV
|
||||
# This is a comment
|
||||
APP_NAME=TestApp
|
||||
|
||||
# Another comment
|
||||
APP_ENV=development
|
||||
|
||||
ENV;
|
||||
|
||||
file_put_contents($this->testDir . '/.env', $envContent);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
expect($env->get('APP_NAME'))->toBe('TestApp');
|
||||
expect($env->get('APP_ENV'))->toBe('development');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Environment-Specific File Loading', function () {
|
||||
it('loads environment-specific .env file', function () {
|
||||
// Base .env
|
||||
file_put_contents($this->testDir . '/.env', <<<ENV
|
||||
APP_NAME=BaseApp
|
||||
APP_ENV=development
|
||||
DB_HOST=localhost
|
||||
ENV
|
||||
);
|
||||
|
||||
// Environment-specific .env.development
|
||||
file_put_contents($this->testDir . '/.env.development', <<<ENV
|
||||
DB_HOST=dev-database
|
||||
DB_DEBUG=true
|
||||
ENV
|
||||
);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
expect($env->get('APP_NAME'))->toBe('BaseApp');
|
||||
expect($env->get('APP_ENV'))->toBe('development');
|
||||
expect($env->get('DB_HOST'))->toBe('dev-database'); // Overridden
|
||||
expect($env->getBool('DB_DEBUG'))->toBeTrue(); // Added
|
||||
});
|
||||
|
||||
it('loads production-specific configuration', function () {
|
||||
// Base .env
|
||||
file_put_contents($this->testDir . '/.env', <<<ENV
|
||||
APP_NAME=MyApp
|
||||
APP_ENV=production
|
||||
DB_HOST=localhost
|
||||
ENV
|
||||
);
|
||||
|
||||
// Environment-specific .env.production
|
||||
file_put_contents($this->testDir . '/.env.production', <<<ENV
|
||||
DB_HOST=prod-database
|
||||
CACHE_DRIVER=redis
|
||||
ENV
|
||||
);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
expect($env->get('APP_NAME'))->toBe('MyApp');
|
||||
expect($env->get('APP_ENV'))->toBe('production');
|
||||
expect($env->get('DB_HOST'))->toBe('prod-database');
|
||||
expect($env->get('CACHE_DRIVER'))->toBe('redis');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker Environment Variables Priority', function () {
|
||||
it('prioritizes Docker ENV vars over .env files in production', function () {
|
||||
// Simulate Docker ENV vars via $_ENV
|
||||
$_ENV['APP_ENV'] = 'production';
|
||||
$_ENV['DB_HOST'] = 'docker-mysql';
|
||||
$_ENV['DB_PORT'] = '3306';
|
||||
|
||||
// .env file with different values
|
||||
file_put_contents($this->testDir . '/.env', <<<ENV
|
||||
APP_ENV=production
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=mydb
|
||||
ENV
|
||||
);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
// Docker ENV vars should win in production
|
||||
expect($env->get('DB_HOST'))->toBe('docker-mysql');
|
||||
expect($env->getInt('DB_PORT'))->toBe(3306);
|
||||
|
||||
// But .env can add new variables
|
||||
expect($env->get('DB_NAME'))->toBe('mydb');
|
||||
});
|
||||
|
||||
it('allows .env files to override in development', function () {
|
||||
// Simulate system ENV vars
|
||||
$_ENV['APP_ENV'] = 'development';
|
||||
$_ENV['DB_HOST'] = 'system-host';
|
||||
|
||||
// .env file with override
|
||||
file_put_contents($this->testDir . '/.env', <<<ENV
|
||||
APP_ENV=development
|
||||
DB_HOST=local-override
|
||||
ENV
|
||||
);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
// .env should override in development
|
||||
expect($env->get('DB_HOST'))->toBe('local-override');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PHP-FPM getenv() Priority Fix', function () {
|
||||
it('prioritizes getenv() over $_ENV in PHP-FPM scenario', function () {
|
||||
// Simulate PHP-FPM scenario where $_ENV is empty but getenv() works
|
||||
$_ENV = []; // Empty like in PHP-FPM
|
||||
|
||||
// Create .env file
|
||||
file_put_contents($this->testDir . '/.env', <<<ENV
|
||||
APP_NAME=FromFile
|
||||
APP_ENV=development
|
||||
ENV
|
||||
);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
// Should load from .env file since getenv() is also empty
|
||||
expect($env->get('APP_NAME'))->toBe('FromFile');
|
||||
expect($env->get('APP_ENV'))->toBe('development');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker Secrets Integration', function () {
|
||||
it('resolves Docker secrets from _FILE variables', function () {
|
||||
// Create secret files
|
||||
$dbPasswordPath = $this->testDir . '/db_password_secret';
|
||||
$apiKeyPath = $this->testDir . '/api_key_secret';
|
||||
|
||||
file_put_contents($dbPasswordPath, 'super_secret_password');
|
||||
file_put_contents($apiKeyPath, 'api_key_12345');
|
||||
|
||||
// .env file with _FILE variables
|
||||
file_put_contents($this->testDir . '/.env', <<<ENV
|
||||
APP_NAME=TestApp
|
||||
DB_PASSWORD_FILE={$dbPasswordPath}
|
||||
API_KEY_FILE={$apiKeyPath}
|
||||
ENV
|
||||
);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
expect($env->get('APP_NAME'))->toBe('TestApp');
|
||||
expect($env->get('DB_PASSWORD'))->toBe('super_secret_password');
|
||||
expect($env->get('API_KEY'))->toBe('api_key_12345');
|
||||
|
||||
// Cleanup secret files
|
||||
unlink($dbPasswordPath);
|
||||
unlink($apiKeyPath);
|
||||
});
|
||||
|
||||
it('prioritizes direct variables over Docker secrets', function () {
|
||||
// Create secret file
|
||||
$secretPath = $this->testDir . '/secret';
|
||||
file_put_contents($secretPath, 'secret_from_file');
|
||||
|
||||
// .env file with both direct and _FILE
|
||||
file_put_contents($this->testDir . '/.env', <<<ENV
|
||||
DB_PASSWORD=direct_password
|
||||
DB_PASSWORD_FILE={$secretPath}
|
||||
ENV
|
||||
);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
// Direct variable should win
|
||||
expect($env->get('DB_PASSWORD'))->toBe('direct_password');
|
||||
|
||||
// Cleanup
|
||||
unlink($secretPath);
|
||||
});
|
||||
|
||||
it('handles missing Docker secret files gracefully', function () {
|
||||
// .env file with _FILE pointing to non-existent file
|
||||
file_put_contents($this->testDir . '/.env', <<<ENV
|
||||
APP_NAME=TestApp
|
||||
DB_PASSWORD_FILE=/non/existent/path
|
||||
ENV
|
||||
);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
expect($env->get('APP_NAME'))->toBe('TestApp');
|
||||
expect($env->get('DB_PASSWORD'))->toBeNull(); // Secret not resolved
|
||||
});
|
||||
});
|
||||
|
||||
describe('Two-Pass Loading with Encryption', function () {
|
||||
it('loads encryption key in first pass and secrets in second pass', function () {
|
||||
// Generate encryption key
|
||||
$encryptionKey = bin2hex(random_bytes(32));
|
||||
|
||||
// Base .env with encryption key
|
||||
file_put_contents($this->testDir . '/.env', <<<ENV
|
||||
APP_NAME=TestApp
|
||||
ENCRYPTION_KEY={$encryptionKey}
|
||||
ENV
|
||||
);
|
||||
|
||||
// Note: .env.secrets would normally contain encrypted values
|
||||
// For this test, we verify the two-pass loading mechanism
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
expect($env->get('APP_NAME'))->toBe('TestApp');
|
||||
expect($env->get('ENCRYPTION_KEY'))->toBe($encryptionKey);
|
||||
});
|
||||
|
||||
it('handles missing encryption key gracefully', function () {
|
||||
// .env without encryption key
|
||||
file_put_contents($this->testDir . '/.env', <<<ENV
|
||||
APP_NAME=TestApp
|
||||
APP_ENV=development
|
||||
ENV
|
||||
);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
// Should load successfully without encryption
|
||||
expect($env->get('APP_NAME'))->toBe('TestApp');
|
||||
expect($env->get('ENCRYPTION_KEY'))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Real-World Scenarios', function () {
|
||||
it('handles multi-layer environment configuration', function () {
|
||||
// Simulate Docker ENV vars
|
||||
$_ENV['APP_ENV'] = 'production';
|
||||
$_ENV['CONTAINER_NAME'] = 'web-01';
|
||||
|
||||
// Base .env
|
||||
file_put_contents($this->testDir . '/.env', <<<ENV
|
||||
APP_NAME=MyApp
|
||||
APP_ENV=production
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
CACHE_DRIVER=file
|
||||
ENV
|
||||
);
|
||||
|
||||
// Environment-specific .env.production
|
||||
file_put_contents($this->testDir . '/.env.production', <<<ENV
|
||||
DB_HOST=prod-db-cluster
|
||||
CACHE_DRIVER=redis
|
||||
REDIS_HOST=redis-cluster
|
||||
ENV
|
||||
);
|
||||
|
||||
// Create Docker secret
|
||||
$dbPasswordPath = $this->testDir . '/db_password';
|
||||
file_put_contents($dbPasswordPath, 'production_password');
|
||||
|
||||
$_ENV['DB_PASSWORD_FILE'] = $dbPasswordPath;
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
// Verify layered loading
|
||||
expect($env->get('APP_NAME'))->toBe('MyApp'); // From .env
|
||||
expect($env->get('APP_ENV'))->toBe('production'); // From Docker ENV (priority)
|
||||
expect($env->get('CONTAINER_NAME'))->toBe('web-01'); // From Docker ENV only
|
||||
expect($env->get('DB_HOST'))->toBe('prod-db-cluster'); // From .env.production
|
||||
expect($env->getInt('DB_PORT'))->toBe(3306); // From .env
|
||||
expect($env->get('CACHE_DRIVER'))->toBe('redis'); // From .env.production (override)
|
||||
expect($env->get('REDIS_HOST'))->toBe('redis-cluster'); // From .env.production
|
||||
expect($env->get('DB_PASSWORD'))->toBe('production_password'); // From Docker secret
|
||||
|
||||
// Cleanup
|
||||
unlink($dbPasswordPath);
|
||||
});
|
||||
|
||||
it('handles database connection string configuration', function () {
|
||||
$envContent = <<<ENV
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=myapp_db
|
||||
DB_USERNAME=dbuser
|
||||
DB_PASSWORD=dbpass
|
||||
DB_CHARSET=utf8mb4
|
||||
DB_COLLATION=utf8mb4_unicode_ci
|
||||
ENV;
|
||||
|
||||
file_put_contents($this->testDir . '/.env', $envContent);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
// Verify all database configuration
|
||||
expect($env->get('DB_CONNECTION'))->toBe('mysql');
|
||||
expect($env->get('DB_HOST'))->toBe('127.0.0.1');
|
||||
expect($env->getInt('DB_PORT'))->toBe(3306);
|
||||
expect($env->get('DB_DATABASE'))->toBe('myapp_db');
|
||||
expect($env->get('DB_USERNAME'))->toBe('dbuser');
|
||||
expect($env->get('DB_PASSWORD'))->toBe('dbpass');
|
||||
expect($env->get('DB_CHARSET'))->toBe('utf8mb4');
|
||||
expect($env->get('DB_COLLATION'))->toBe('utf8mb4_unicode_ci');
|
||||
});
|
||||
|
||||
it('handles application-wide feature flags', function () {
|
||||
$envContent = <<<ENV
|
||||
APP_NAME=MyApp
|
||||
FEATURE_NEW_UI=true
|
||||
FEATURE_BETA_ANALYTICS=false
|
||||
FEATURE_EXPERIMENTAL_CACHE=true
|
||||
MAINTENANCE_MODE=false
|
||||
DEBUG_TOOLBAR=true
|
||||
ENV;
|
||||
|
||||
file_put_contents($this->testDir . '/.env', $envContent);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
expect($env->get('APP_NAME'))->toBe('MyApp');
|
||||
expect($env->getBool('FEATURE_NEW_UI'))->toBeTrue();
|
||||
expect($env->getBool('FEATURE_BETA_ANALYTICS'))->toBeFalse();
|
||||
expect($env->getBool('FEATURE_EXPERIMENTAL_CACHE'))->toBeTrue();
|
||||
expect($env->getBool('MAINTENANCE_MODE'))->toBeFalse();
|
||||
expect($env->getBool('DEBUG_TOOLBAR'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('handles URL and connection strings', function () {
|
||||
$envContent = <<<ENV
|
||||
APP_URL=https://myapp.com
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
DATABASE_URL=mysql://user:pass@localhost:3306/dbname
|
||||
ELASTICSEARCH_URL=http://localhost:9200
|
||||
ENV;
|
||||
|
||||
file_put_contents($this->testDir . '/.env', $envContent);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
expect($env->get('APP_URL'))->toBe('https://myapp.com');
|
||||
expect($env->get('REDIS_URL'))->toBe('redis://localhost:6379/0');
|
||||
expect($env->get('DATABASE_URL'))->toBe('mysql://user:pass@localhost:3306/dbname');
|
||||
expect($env->get('ELASTICSEARCH_URL'))->toBe('http://localhost:9200');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', function () {
|
||||
it('handles missing .env file gracefully', function () {
|
||||
// No .env file created
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
// Should return environment with only system variables
|
||||
expect($env)->toBeInstanceOf(Environment::class);
|
||||
});
|
||||
|
||||
it('handles corrupted .env file gracefully', function () {
|
||||
// Create invalid .env file
|
||||
file_put_contents($this->testDir . '/.env', <<<ENV
|
||||
VALID_VAR=value
|
||||
INVALID LINE WITHOUT EQUALS
|
||||
ANOTHER_VALID=value2
|
||||
ENV
|
||||
);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
// Should skip invalid lines and load valid ones
|
||||
expect($env->get('VALID_VAR'))->toBe('value');
|
||||
expect($env->get('ANOTHER_VALID'))->toBe('value2');
|
||||
});
|
||||
|
||||
it('handles unreadable .env file gracefully', function () {
|
||||
// Create .env file and make it unreadable
|
||||
file_put_contents($this->testDir . '/.env', 'APP_NAME=Test');
|
||||
chmod($this->testDir . '/.env', 0000);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
// Should handle gracefully
|
||||
expect($env)->toBeInstanceOf(Environment::class);
|
||||
|
||||
// Restore permissions for cleanup
|
||||
chmod($this->testDir . '/.env', 0644);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EnvKey Enum Integration', function () {
|
||||
it('works with EnvKey enum throughout loading', function () {
|
||||
$envContent = <<<ENV
|
||||
APP_NAME=TestApp
|
||||
APP_ENV=production
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
ENV;
|
||||
|
||||
file_put_contents($this->testDir . '/.env', $envContent);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
// Use EnvKey enum for access
|
||||
expect($env->get(EnvKey::APP_NAME))->toBe('TestApp');
|
||||
expect($env->get(EnvKey::APP_ENV))->toBe('production');
|
||||
expect($env->get(EnvKey::DB_HOST))->toBe('localhost');
|
||||
expect($env->getInt(EnvKey::DB_PORT))->toBe(3306);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance and Memory', function () {
|
||||
it('handles large .env files efficiently', function () {
|
||||
// Generate large .env file
|
||||
$lines = [];
|
||||
for ($i = 0; $i < 1000; $i++) {
|
||||
$lines[] = "VAR_{$i}=value_{$i}";
|
||||
}
|
||||
file_put_contents($this->testDir . '/.env', implode("\n", $lines));
|
||||
|
||||
$startMemory = memory_get_usage();
|
||||
$startTime = microtime(true);
|
||||
|
||||
$env = $this->loader->load($this->testDir);
|
||||
|
||||
$endTime = microtime(true);
|
||||
$endMemory = memory_get_usage();
|
||||
|
||||
$duration = ($endTime - $startTime) * 1000; // Convert to ms
|
||||
$memoryUsed = ($endMemory - $startMemory) / 1024 / 1024; // Convert to MB
|
||||
|
||||
// Verify all variables loaded
|
||||
expect($env->get('VAR_0'))->toBe('value_0');
|
||||
expect($env->get('VAR_999'))->toBe('value_999');
|
||||
|
||||
// Performance assertions (generous limits for CI environments)
|
||||
expect($duration)->toBeLessThan(1000); // Should load in under 1 second
|
||||
expect($memoryUsed)->toBeLessThan(10); // Should use less than 10MB
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Cache\SmartCache;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\EntityManager;
|
||||
use App\Framework\Database\Schema\Schema;
|
||||
use App\Framework\Http\RequestContext;
|
||||
use App\Framework\LiveComponents\Attributes\TrackStateHistory;
|
||||
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
||||
use App\Framework\LiveComponents\Persistence\LiveComponentStatePersistence;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
||||
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
|
||||
use App\Framework\StateManagement\Database\DatabaseStateHistoryManager;
|
||||
use App\Framework\StateManagement\Database\DatabaseStateManager;
|
||||
use App\Framework\StateManagement\SerializableState;
|
||||
|
||||
// Test State
|
||||
final readonly class IntegrationTestState implements SerializableState
|
||||
{
|
||||
public function __construct(
|
||||
public int $counter = 0,
|
||||
public string $message = '',
|
||||
public array $items = []
|
||||
) {}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'counter' => $this->counter,
|
||||
'message' => $this->message,
|
||||
'items' => $this->items,
|
||||
];
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
counter: $data['counter'] ?? 0,
|
||||
message: $data['message'] ?? '',
|
||||
items: $data['items'] ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Test Component with History
|
||||
#[TrackStateHistory(
|
||||
trackIpAddress: true,
|
||||
trackUserAgent: true,
|
||||
trackChangedProperties: true,
|
||||
maxHistoryEntries: 10
|
||||
)]
|
||||
final readonly class IntegrationTestComponent implements LiveComponentContract
|
||||
{
|
||||
public function __construct(
|
||||
public ComponentId $id,
|
||||
public IntegrationTestState $state
|
||||
) {}
|
||||
|
||||
public function getRenderData(): ComponentRenderData
|
||||
{
|
||||
return new ComponentRenderData(
|
||||
templatePath: 'components/integration-test',
|
||||
data: ['state' => $this->state]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Database State Integration', function () {
|
||||
beforeEach(function () {
|
||||
// Real dependencies for integration test
|
||||
// Use in-memory SQLite for testing
|
||||
$pdo = new PDO('sqlite::memory:');
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
$this->connection = new class($pdo) implements ConnectionInterface {
|
||||
public function __construct(private PDO $pdo) {}
|
||||
|
||||
public function getPdo(): PDO
|
||||
{
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
public function query(string $sql, array $params = []): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function execute(string $sql, array $params = []): int
|
||||
{
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
public function lastInsertId(): string
|
||||
{
|
||||
return $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
public function beginTransaction(): void
|
||||
{
|
||||
$this->pdo->beginTransaction();
|
||||
}
|
||||
|
||||
public function commit(): void
|
||||
{
|
||||
$this->pdo->commit();
|
||||
}
|
||||
|
||||
public function rollback(): void
|
||||
{
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
|
||||
public function inTransaction(): bool
|
||||
{
|
||||
return $this->pdo->inTransaction();
|
||||
}
|
||||
};
|
||||
$this->entityManager = new EntityManager($this->connection);
|
||||
$this->cache = new SmartCache();
|
||||
|
||||
// Create state manager
|
||||
$this->stateManager = new DatabaseStateManager(
|
||||
entityManager: $this->entityManager,
|
||||
cache: $this->cache,
|
||||
stateClass: IntegrationTestState::class,
|
||||
logger: null,
|
||||
cacheTtl: Duration::fromSeconds(60)
|
||||
);
|
||||
|
||||
// Create history manager
|
||||
$this->historyManager = new DatabaseStateHistoryManager(
|
||||
entityManager: $this->entityManager,
|
||||
logger: null
|
||||
);
|
||||
|
||||
// Create request context
|
||||
$this->requestContext = new RequestContext(
|
||||
userId: 'test-user-123',
|
||||
sessionId: 'test-session-456',
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'Test-Agent/1.0'
|
||||
);
|
||||
|
||||
// Create persistence handler
|
||||
$this->persistence = new LiveComponentStatePersistence(
|
||||
stateManager: $this->stateManager,
|
||||
historyManager: $this->historyManager,
|
||||
requestContext: $this->requestContext,
|
||||
logger: null
|
||||
);
|
||||
|
||||
// Setup database tables
|
||||
// Create component_state table
|
||||
$this->connection->execute("
|
||||
CREATE TABLE component_state (
|
||||
component_id TEXT PRIMARY KEY,
|
||||
state_data TEXT NOT NULL,
|
||||
state_class TEXT NOT NULL,
|
||||
component_name TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
session_id TEXT,
|
||||
version INTEGER DEFAULT 1,
|
||||
checksum TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
expires_at TEXT
|
||||
)
|
||||
");
|
||||
|
||||
// Create component_state_history table
|
||||
$this->connection->execute("
|
||||
CREATE TABLE component_state_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
component_id TEXT NOT NULL,
|
||||
state_data TEXT NOT NULL,
|
||||
state_class TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
change_type TEXT NOT NULL,
|
||||
changed_properties TEXT,
|
||||
user_id TEXT,
|
||||
session_id TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
previous_checksum TEXT,
|
||||
current_checksum TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (component_id) REFERENCES component_state(component_id) ON DELETE CASCADE
|
||||
)
|
||||
");
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Cleanup test tables
|
||||
$this->connection->execute("DROP TABLE IF EXISTS component_state_history");
|
||||
$this->connection->execute("DROP TABLE IF EXISTS component_state");
|
||||
});
|
||||
|
||||
it('persists component state to database', function () {
|
||||
$componentId = new ComponentId('counter', 'test-1');
|
||||
$state = new IntegrationTestState(
|
||||
counter: 42,
|
||||
message: 'Hello Integration Test',
|
||||
items: ['item1', 'item2']
|
||||
);
|
||||
$component = new IntegrationTestComponent($componentId, $state);
|
||||
|
||||
// Persist state
|
||||
$this->persistence->persistState($component, $state, 'increment');
|
||||
|
||||
// Verify state was saved
|
||||
$retrieved = $this->stateManager->getState($componentId->toString());
|
||||
|
||||
expect($retrieved)->toBeInstanceOf(IntegrationTestState::class);
|
||||
expect($retrieved->counter)->toBe(42);
|
||||
expect($retrieved->message)->toBe('Hello Integration Test');
|
||||
expect($retrieved->items)->toBe(['item1', 'item2']);
|
||||
});
|
||||
|
||||
it('tracks state changes in history', function () {
|
||||
$componentId = new ComponentId('counter', 'test-2');
|
||||
$component = new IntegrationTestComponent(
|
||||
$componentId,
|
||||
new IntegrationTestState(counter: 0, message: 'initial')
|
||||
);
|
||||
|
||||
// Create initial state
|
||||
$state1 = new IntegrationTestState(counter: 0, message: 'initial');
|
||||
$this->persistence->persistState($component, $state1, 'init');
|
||||
|
||||
// Update state
|
||||
$state2 = new IntegrationTestState(counter: 1, message: 'updated');
|
||||
$component = new IntegrationTestComponent($componentId, $state2);
|
||||
$this->persistence->persistState($component, $state2, 'increment');
|
||||
|
||||
// Update again
|
||||
$state3 = new IntegrationTestState(counter: 2, message: 'updated again');
|
||||
$component = new IntegrationTestComponent($componentId, $state3);
|
||||
$this->persistence->persistState($component, $state3, 'increment');
|
||||
|
||||
// Get history
|
||||
$history = $this->historyManager->getHistory($componentId->toString());
|
||||
|
||||
expect($history)->toBeArray();
|
||||
expect(count($history))->toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Verify history entries are ordered DESC
|
||||
expect($history[0]->version)->toBeGreaterThan($history[1]->version);
|
||||
|
||||
// Verify context was captured
|
||||
expect($history[0]->userId)->toBe('test-user-123');
|
||||
expect($history[0]->sessionId)->toBe('test-session-456');
|
||||
expect($history[0]->ipAddress)->toBe('127.0.0.1');
|
||||
expect($history[0]->userAgent)->toBe('Test-Agent/1.0');
|
||||
});
|
||||
|
||||
it('uses cache for fast retrieval after initial load', function () {
|
||||
$componentId = new ComponentId('counter', 'test-3');
|
||||
$state = new IntegrationTestState(counter: 99, message: 'cached');
|
||||
$component = new IntegrationTestComponent($componentId, $state);
|
||||
|
||||
// First persist (cold)
|
||||
$this->persistence->persistState($component, $state, 'test');
|
||||
|
||||
// Get state (should populate cache)
|
||||
$retrieved1 = $this->stateManager->getState($componentId->toString());
|
||||
|
||||
// Get state again (should hit cache)
|
||||
$retrieved2 = $this->stateManager->getState($componentId->toString());
|
||||
|
||||
expect($retrieved1->counter)->toBe(99);
|
||||
expect($retrieved2->counter)->toBe(99);
|
||||
|
||||
// Verify cache statistics show hits
|
||||
$stats = $this->stateManager->getStatistics();
|
||||
expect($stats->hitCount)->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('tracks changed properties correctly', function () {
|
||||
$componentId = new ComponentId('counter', 'test-4');
|
||||
|
||||
// Initial state
|
||||
$state1 = new IntegrationTestState(
|
||||
counter: 10,
|
||||
message: 'first',
|
||||
items: ['a', 'b']
|
||||
);
|
||||
$component = new IntegrationTestComponent($componentId, $state1);
|
||||
$this->persistence->persistState($component, $state1, 'init');
|
||||
|
||||
// Update only counter
|
||||
$state2 = new IntegrationTestState(
|
||||
counter: 11,
|
||||
message: 'first', // Same
|
||||
items: ['a', 'b'] // Same
|
||||
);
|
||||
$component = new IntegrationTestComponent($componentId, $state2);
|
||||
$this->persistence->persistState($component, $state2, 'increment');
|
||||
|
||||
// Get latest history entry
|
||||
$history = $this->historyManager->getHistory($componentId->toString(), limit: 1);
|
||||
$latestEntry = $history[0];
|
||||
|
||||
// Should only track 'counter' as changed
|
||||
expect($latestEntry->changedProperties)->toBeArray();
|
||||
expect($latestEntry->changedProperties)->toContain('counter');
|
||||
expect(count($latestEntry->changedProperties))->toBe(1);
|
||||
});
|
||||
|
||||
it('supports atomic state updates', function () {
|
||||
$componentId = new ComponentId('counter', 'test-5');
|
||||
$initialState = new IntegrationTestState(counter: 0, message: 'start');
|
||||
$component = new IntegrationTestComponent($componentId, $initialState);
|
||||
|
||||
// Persist initial state
|
||||
$this->persistence->persistState($component, $initialState, 'init');
|
||||
|
||||
// Atomic update
|
||||
$updatedState = $this->stateManager->updateState(
|
||||
$componentId->toString(),
|
||||
fn(IntegrationTestState $state) => new IntegrationTestState(
|
||||
counter: $state->counter + 5,
|
||||
message: 'updated',
|
||||
items: $state->items
|
||||
)
|
||||
);
|
||||
|
||||
expect($updatedState)->toBeInstanceOf(IntegrationTestState::class);
|
||||
expect($updatedState->counter)->toBe(5);
|
||||
expect($updatedState->message)->toBe('updated');
|
||||
});
|
||||
|
||||
it('retrieves specific version from history', function () {
|
||||
$componentId = new ComponentId('counter', 'test-6');
|
||||
|
||||
// Create multiple versions
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$state = new IntegrationTestState(
|
||||
counter: $i,
|
||||
message: "version {$i}"
|
||||
);
|
||||
$component = new IntegrationTestComponent($componentId, $state);
|
||||
$this->persistence->persistState($component, $state, 'update');
|
||||
}
|
||||
|
||||
// Get version 3
|
||||
$version3 = $this->historyManager->getHistoryByVersion($componentId->toString(), 3);
|
||||
|
||||
expect($version3)->not->toBeNull();
|
||||
expect($version3->version)->toBe(3);
|
||||
|
||||
$state3 = IntegrationTestState::fromArray(json_decode($version3->stateData, true));
|
||||
expect($state3->counter)->toBe(3);
|
||||
expect($state3->message)->toBe('version 3');
|
||||
});
|
||||
|
||||
it('cleans up old history entries', function () {
|
||||
$componentId = new ComponentId('counter', 'test-7');
|
||||
|
||||
// Create 10 history entries
|
||||
for ($i = 1; $i <= 10; $i++) {
|
||||
$state = new IntegrationTestState(counter: $i);
|
||||
$component = new IntegrationTestComponent($componentId, $state);
|
||||
$this->persistence->persistState($component, $state, 'update');
|
||||
}
|
||||
|
||||
// Keep only last 5
|
||||
$deleted = $this->historyManager->cleanup($componentId->toString(), keepLast: 5);
|
||||
|
||||
expect($deleted)->toBe(5);
|
||||
|
||||
// Verify only 5 entries remain
|
||||
$history = $this->historyManager->getHistory($componentId->toString());
|
||||
expect(count($history))->toBe(5);
|
||||
});
|
||||
|
||||
it('provides statistics about state storage', function () {
|
||||
// Create multiple components
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$componentId = new ComponentId('counter', "test-stats-{$i}");
|
||||
$state = new IntegrationTestState(counter: $i);
|
||||
$component = new IntegrationTestComponent($componentId, $state);
|
||||
$this->persistence->persistState($component, $state, 'create');
|
||||
}
|
||||
|
||||
// Get statistics
|
||||
$stats = $this->stateManager->getStatistics();
|
||||
|
||||
expect($stats->totalKeys)->toBeGreaterThanOrEqual(3);
|
||||
expect($stats->setCount)->toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user