connection = new TestConnection(); $this->platform = new MySqlPlatform(); $this->clock = new SystemClock(); $this->memoryMonitor = new MemoryMonitor(); $this->operationTracker = new OperationTracker(); $this->ulidGenerator = new class implements UlidGenerator { public function generate(): string { return '01ARZ3NDEKTSV4RRFFQ69G5FAV'; } }; $this->appConfig = new AppConfig(\App\Framework\Config\EnvironmentType::DEVELOPMENT); $tableConfig = MigrationTableConfig::withCustomTable('test_migrations'); $dependencyGraph = new MigrationDependencyGraph(); $databaseManager = new MigrationDatabaseManager( $this->connection, $this->platform, $this->clock, $tableConfig ); $performanceTracker = new MigrationPerformanceTracker( $this->operationTracker, $this->memoryMonitor, null, null, null ); $migrationLogger = new MigrationLogger(null); $validator = new MigrationValidator( $this->connection, $this->platform, $this->appConfig ); $errorAnalyzer = new MigrationErrorAnalyzer(); $this->migrationRunner = new MigrationRunner( $this->connection, $this->platform, $this->clock, $this->ulidGenerator, $this->appConfig, $dependencyGraph, $databaseManager, $performanceTracker, $migrationLogger, $validator, $errorAnalyzer ); }); test('constructor creates migrations table', function () { // Constructor should have already been called in beforeEach $queries = $this->connection->getQueries(); expect($queries)->toHaveCount(1) ->and($queries[0]['type'])->toBe('execute') ->and($queries[0]['sql'])->toContain('CREATE TABLE') ->and($queries[0]['sql'])->toContain('test_migrations'); }); test('migrate runs pending migrations', function () { // Mock migration $migration = new TestMigration(); $migrations = new MigrationCollection($migration); // Set no applied migrations initially $this->connection->setAppliedMigrations([]); $result = $this->migrationRunner->migrate($migrations); expect($result)->toContain('2024_01_01_000000'); expect($migration->wasExecuted())->toBeTrue(); // Verify the migration was recorded $queries = $this->connection->getQueries(); $insertQueries = array_filter( $queries, fn ($q) => $q['type'] === 'execute' && str_contains($q['sql'], 'INSERT INTO') ); expect($insertQueries)->toHaveCount(1); }); test('migrate skips already applied migrations', function () { $migration = new TestMigration(); $migrations = new MigrationCollection($migration); // Set migration as already applied $this->connection->setAppliedMigrations([['version' => '2024_01_01_000000']]); $result = $this->migrationRunner->migrate($migrations); expect($result)->toBeEmpty(); expect($migration->wasExecuted())->toBeFalse(); }); test('migrate rolls back on failure', function () { // Mock failing migration $failingMigration = new FailingTestMigration(); $migrations = new MigrationCollection($failingMigration); // Set no applied migrations initially $this->connection->setAppliedMigrations([]); $this->connection->setShouldFail(true); expect(fn () => $this->migrationRunner->migrate($migrations)) ->toThrow(DatabaseException::class); // Verify transaction was used (inTransaction was called) expect($this->connection->inTransaction())->toBeFalse(); // Should be rolled back }); test('rollback reverts applied migration', function () { $migration = new TestMigration(); $migrations = new MigrationCollection($migration); // Set migration as already applied $this->connection->setAppliedMigrations([['version' => '2024_01_01_000000']]); $result = $this->migrationRunner->rollback($migrations, 1); expect($result)->toHaveCount(1); expect($migration->wasRolledBack())->toBeTrue(); // Verify the migration record was deleted $queries = $this->connection->getQueries(); $deleteQueries = array_filter( $queries, fn ($q) => $q['type'] === 'execute' && str_contains($q['sql'], 'DELETE FROM') ); expect($deleteQueries)->toHaveCount(1); }); test('get status returns migration status', function () { $migration1 = new TestMigration(); $migration2 = new class implements Migration { public function up(ConnectionInterface $connection): void {} public function down(ConnectionInterface $connection): void {} public function getDescription(): string { return 'Pending Migration'; } public function getVersion(): \App\Framework\Database\Migration\MigrationVersion { return \App\Framework\Database\Migration\MigrationVersion::fromTimestamp('2024_01_02_000000'); } }; $migrations = new MigrationCollection($migration1, $migration2); // Set only first migration as applied $this->connection->setAppliedMigrations([['version' => '2024_01_01_000000']]); $status = $this->migrationRunner->getStatus($migrations); expect($status)->toHaveCount(2) ->and($status[0]->applied)->toBeTrue() ->and($status[1]->applied)->toBeFalse(); }); // Test fixtures class TestMigration implements Migration { private bool $executed = false; private bool $rolledBack = false; public function up(ConnectionInterface $connection): void { $this->executed = true; // Simulate migration execution $connection->execute(SqlQuery::create('CREATE TABLE test_table (id INT)')); } public function down(ConnectionInterface $connection): void { $this->rolledBack = true; // Simulate migration rollback $connection->execute(SqlQuery::create('DROP TABLE test_table')); } public function getDescription(): string { return 'Test Migration'; } public function getVersion(): \App\Framework\Database\Migration\MigrationVersion { return \App\Framework\Database\Migration\MigrationVersion::fromTimestamp('2024_01_01_000000'); } public function wasExecuted(): bool { return $this->executed; } public function wasRolledBack(): bool { return $this->rolledBack; } } class FailingTestMigration implements Migration { public function up(ConnectionInterface $connection): void { throw new \Exception('Migration failed'); } public function down(ConnectionInterface $connection): void { // No-op } public function getDescription(): string { return 'Failing Migration'; } public function getVersion(): \App\Framework\Database\Migration\MigrationVersion { return \App\Framework\Database\Migration\MigrationVersion::fromTimestamp('2024_01_01_000000'); } } class TestConnection implements ConnectionInterface { private array $queries = []; private array $appliedMigrations = []; private bool $inTransaction = false; private bool $shouldFail = false; public function setAppliedMigrations(array $migrations): void { $this->appliedMigrations = $migrations; } public function setShouldFail(bool $fail): void { $this->shouldFail = $fail; } public function execute(SqlQuery $query): int { $this->queries[] = ['type' => 'execute', 'sql' => $query->sql, 'params' => $query->parameters->toArray()]; if ($this->shouldFail && strpos($query->sql, 'INSERT INTO') !== false) { throw new DatabaseException('Simulated database failure'); } return 1; } public function query(SqlQuery $query): ResultInterface { $this->queries[] = ['type' => 'query', 'sql' => $query->sql, 'params' => $query->parameters->toArray()]; return new TestResult($this->appliedMigrations); } public function queryOne(SqlQuery $query): ?array { return null; } public function queryColumn(SqlQuery $query): array { $this->queries[] = ['type' => 'queryColumn', 'sql' => $query->sql, 'params' => $query->parameters->toArray()]; // Return the versions from applied migrations return array_column($this->appliedMigrations, 'version'); } public function queryScalar(SqlQuery $query): mixed { return null; } public function beginTransaction(): void { $this->inTransaction = true; } public function commit(): void { $this->inTransaction = false; } public function rollback(): void { $this->inTransaction = false; } public function inTransaction(): bool { return $this->inTransaction; } public function lastInsertId(): string { return '1'; } public function getPdo(): \PDO { return new class () extends \PDO { public function __construct() { // Skip parent constructor to avoid actual DB connection } public function getAttribute(int $attribute): mixed { if ($attribute === \PDO::ATTR_DRIVER_NAME) { return 'sqlite'; } return null; } }; } public function getQueries(): array { return $this->queries; } } class TestResult implements ResultInterface { private array $data; private int $position = 0; public function __construct(array $data = []) { $this->data = $data; } public function fetch(): ?array { if ($this->position >= count($this->data)) { return null; } return $this->data[$this->position++]; } public function fetchAll(): array { return $this->data; } public function fetchColumn(int $column = 0): array { return array_column($this->data, $column); } public function fetchScalar(): mixed { $row = $this->fetch(); return $row ? reset($row) : null; } public function rowCount(): int { return count($this->data); } public function getIterator(): \Iterator { return new \ArrayIterator($this->data); } public function count(): int { return count($this->data); } }