connection = new TestConnection(); $this->platform = new MySqlPlatform(); $this->clock = new SystemClock(); $this->memoryMonitor = new MemoryMonitor(); $this->operationTracker = new OperationTracker(); $this->migrationRunner = new MigrationRunner( $this->connection, $this->platform, $this->clock, null, // tableConfig null, // logger $this->operationTracker, $this->memoryMonitor ); }); 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 IF NOT EXISTS 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(); $migrationData = (object) [ 'version' => '2024_01_01_000000', 'description' => 'Test Migration', 'instance' => $migration, ]; // Set migration as already applied $this->connection->setAppliedMigrations([['version' => '2024_01_01_000000']]); $result = $this->migrationRunner->migrate([$migrationData]); expect($result)->toBeEmpty(); expect($migration->wasExecuted())->toBeFalse(); }); test('migrate rolls back on failure', function () { // Mock failing migration $failingMigration = new FailingTestMigration(); $migrationData = (object) [ 'version' => '2024_01_01_000000', 'description' => 'Failing Migration', 'instance' => $failingMigration, ]; // Set no applied migrations initially $this->connection->setAppliedMigrations([]); $this->connection->setShouldFail(true); expect(fn () => $this->migrationRunner->migrate([$migrationData])) ->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(); $migrationData = (object) [ 'version' => '2024_01_01_000000', 'description' => 'Test Migration', 'instance' => $migration, ]; // Set migration as already applied $this->connection->setAppliedMigrations([['version' => '2024_01_01_000000']]); $result = $this->migrationRunner->rollback([$migrationData], 1); expect($result)->toContain('2024_01_01_000000'); 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 test_migrations') ); expect($deleteQueries)->toHaveCount(1); }); test('get status returns migration status', function () { $migration1Data = (object) [ 'version' => '2024_01_01_000000', 'description' => 'Applied Migration', 'instance' => new TestMigration(), ]; $migration2Data = (object) [ 'version' => '2024_01_02_000000', 'description' => 'Pending Migration', 'instance' => new TestMigration(), ]; // Set only first migration as applied $this->connection->setAppliedMigrations([['version' => '2024_01_01_000000']]); $status = $this->migrationRunner->getStatus([$migration1Data, $migration2Data]); 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('CREATE TABLE test_table (id INT)'); } public function down(ConnectionInterface $connection): void { $this->rolledBack = true; // Simulate migration rollback $connection->execute('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); } }