* SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ declare(strict_types=1); namespace App\Tests\Db; use App\Db\Migrator; use PDO; use PHPUnit\Framework\TestCase; /** * R01-N22: pin the contract that `Migrator::pendingFiles()` lets the request * path detect a deploy-skipped migration without applying SQL itself, and * that `migrate()` is still the apply path used by `bin/migrate.php`. */ final class MigratorTest extends TestCase { private string $tmpDir; protected function setUp(): void { $this->tmpDir = sys_get_temp_dir() . '/spw-migrator-' . bin2hex(random_bytes(4)); if (!mkdir($this->tmpDir) && !is_dir($this->tmpDir)) { $this->fail('cannot mkdir tmp'); } } protected function tearDown(): void { foreach (glob($this->tmpDir . '/*') ?: [] as $f) { @unlink($f); } @rmdir($this->tmpDir); } private function makePdo(): PDO { $pdo = new PDO('sqlite::memory:', null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]); $pdo->exec('PRAGMA foreign_keys = ON'); return $pdo; } private function writeMigration(string $name, string $sql): void { file_put_contents($this->tmpDir . DIRECTORY_SEPARATOR . $name, $sql); } public function testPendingFilesReturnsEverythingOnVirginDb(): void { $this->writeMigration('001_init.sql', 'CREATE TABLE a (x INTEGER);'); $this->writeMigration('002_more.sql', 'CREATE TABLE b (x INTEGER);'); $migrator = new Migrator($this->makePdo(), $this->tmpDir); $this->assertSame(['001_init.sql', '002_more.sql'], $migrator->pendingFiles()); } public function testPendingFilesIsEmptyAfterMigrate(): void { $this->writeMigration('001_init.sql', 'CREATE TABLE a (x INTEGER);'); $this->writeMigration('002_more.sql', 'CREATE TABLE b (x INTEGER);'); $migrator = new Migrator($this->makePdo(), $this->tmpDir); $migrator->migrate(); $this->assertSame([], $migrator->pendingFiles()); $this->assertSame(2, $migrator->currentVersion()); } public function testPendingFilesReportsOnlyNewlyAdded(): void { $this->writeMigration('001_init.sql', 'CREATE TABLE a (x INTEGER);'); $pdo = $this->makePdo(); (new Migrator($pdo, $this->tmpDir))->migrate(); // Operator dropped a new file in but didn't re-run migrate. $this->writeMigration('002_more.sql', 'CREATE TABLE b (x INTEGER);'); $migrator = new Migrator($pdo, $this->tmpDir); $this->assertSame(['002_more.sql'], $migrator->pendingFiles()); } public function testPendingFilesIgnoresFilesWithoutVersionPrefix(): void { $this->writeMigration('001_init.sql', 'CREATE TABLE a (x INTEGER);'); $this->writeMigration('README.md', '# notes'); $this->writeMigration('foo_bar.sql', 'CREATE TABLE c (x INTEGER);'); $migrator = new Migrator($this->makePdo(), $this->tmpDir); $this->assertSame(['001_init.sql'], $migrator->pendingFiles()); } public function testPendingFilesCreatesSchemaVersionTable(): void { // Empty dir: even without any migrations the call must not blow up // and the version table must exist (idempotent ensure). $pdo = $this->makePdo(); $migrator = new Migrator($pdo, $this->tmpDir); $this->assertSame([], $migrator->pendingFiles()); $row = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'")->fetch(); $this->assertIsArray($row); $this->assertSame('schema_version', $row['name']); } public function testRealMigrationsDirectoryHasNoPendingAfterFullApply(): void { // Smoke test against the real migrations/ folder so a future reviewer // can't drop a malformed file in without the suite noticing. $pdo = $this->makePdo(); $migrator = new Migrator($pdo); $migrator->migrate(); $this->assertSame([], $migrator->pendingFiles(), 'real migrations apply cleanly'); } }