Files
michaelschiemer/tests/Framework/Database/Migration/MigrationRunnerTest.php
Michael Schiemer 5050c7d73a docs: consolidate documentation into organized structure
- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
2025-10-05 11:05:04 +02:00

375 lines
9.9 KiB
PHP

<?php
declare(strict_types=1);
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationCollection;
use App\Framework\Database\Migration\MigrationRunner;
use App\Framework\Database\Platform\MySqlPlatform;
use App\Framework\Database\ResultInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\DateTime\SystemClock;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Performance\OperationTracker;
beforeEach(function () {
$this->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);
}
}