Files
XC_VM/tests/Unit/MigrationRunnerRollbackTest.php
T
Divarion_D d9ae0a253d feat(update): reverse DB migrations on version rollback
Rollback previously swapped in older code but never touched schema
changes newer migrations applied — the rollback's own comment admitted
"migrations are forward-only, so a downgrade cannot undo them". Of the
25 core migrations, two actually DROP a column older code still reads
(013_drop_asn_allocated, 016_per_repo_update_channel); rolling back
past either without restoring it would crash the restored old code.

Add MigrationRunner::rollback(): diffs the currently-applied migration
set (the `migrations` tracking table) against the TARGET version's own
migrations/database/up/ file list — read straight out of the
already-downloaded rollback tarball via the new
ReleaseArchiveInspector (PharData/tar, no extra network call, no full
extraction) — and reverses, in descending order, every migration the
target version doesn't carry. Only migrations with a down.sql
counterpart are reversed; migrations lacking one are left applied and
logged as skipped, which is safe by design since every migration
without a down file only ever ADDs schema or business data that older
code simply never references.

Restructured src/migrations/ into database/up/ (the 25 existing
forward migrations, unchanged content) and database/down/ (18 new
reverse migrations: the two critical drops plus every mechanically-safe
ADD-column/cron-row inverse; excluded are three migrations touching
real business data — activation_codes, category_templates, lines'
custom_data — and four data UPDATEs with unrecoverable prior values).
Kept as two directories rather than a `.down.sql`-suffixed sibling of
the up file: glob('*.sql') in run() would otherwise pick up the down
file too (it still ends in .sql) and try to run it forward.

Wired into UpdateCommand's rollback case right after the tarball
download+MD5 verification (MAIN only, aborts the rollback on failure —
the pre-rollback DB backup remains the recovery path either way).

Also extends the shared TestDb harness (tests/Support/TestDb.php) to
strip MySQL-only ENGINE=/CHARSET=/COLLATE= table-option clauses under
its default SQLite backend, so MigrationRunner's real production DDL
can be exercised in tests without a live MariaDB.
2026-09-24 22:12:44 +03:00

116 lines
4.0 KiB
PHP

<?php
use XcVm\Core\Database\MigrationRunner;
use PHPUnit\Framework\TestCase;
/**
* MigrationRunner::rollback() — reversing migrations on a version downgrade.
*
* Runs the real diff/down-file/tracking-row logic against a throwaway
* migrations directory and an in-memory SQLite handle (mirrors
* ModuleMigratorTest's approach for the module-side equivalent).
*/
final class MigrationRunnerRollbackTest extends TestCase {
private string $root;
private TestDb $db;
protected function setUp(): void {
$this->root = sys_get_temp_dir() . '/xcvm_rollback_' . uniqid('', true);
mkdir($this->root, 0755, true);
$this->db = new TestDb();
}
protected function tearDown(): void {
$this->rrmdir($this->root);
}
private function rrmdir(string $dir): void {
if (!is_dir($dir)) {
return;
}
foreach (scandir($dir) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir . '/' . $entry;
is_dir($path) ? $this->rrmdir($path) : unlink($path);
}
rmdir($dir);
}
private function writeDown(string $migrationName, string $sql): void {
if (!is_dir($this->root . '/down')) {
mkdir($this->root . '/down', 0755, true);
}
file_put_contents($this->root . '/down/' . $migrationName, $sql);
}
/** Mark $names as already-applied in the migrations tracking table. */
private function markApplied(array $names): void {
$this->db->query("CREATE TABLE IF NOT EXISTS `migrations` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`migration` VARCHAR(255) NOT NULL UNIQUE,
`applied_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);");
foreach ($names as $name) {
$this->db->query('INSERT INTO `migrations` (`migration`) VALUES (?);', $name);
}
}
private function appliedNames(): array {
$this->db->query('SELECT `migration` FROM `migrations`;');
return array_column($this->db->get_rows(), 'migration');
}
public function testRollbackReversesMigrationsMissingFromTargetInDescendingOrder(): void {
$this->markApplied(['001_a.sql', '002_b.sql', '003_c.sql']);
$this->db->query('CREATE TABLE probe (id INTEGER PRIMARY KEY, tag TEXT);');
$this->writeDown('002_b.sql', "INSERT INTO probe (tag) VALUES ('down-002');");
$this->writeDown('003_c.sql', "INSERT INTO probe (tag) VALUES ('down-003');");
$result = MigrationRunner::rollback($this->db, ['001_a.sql'], $this->root . '/');
$this->assertSame(['003_c.sql', '002_b.sql'], $result['reversed'], 'descending order');
$this->assertSame([], $result['skipped']);
$this->db->query('SELECT tag FROM probe ORDER BY id;');
$this->assertSame(['down-003', 'down-002'], $this->db->get_column(), 'later down ran before earlier one');
$this->assertSame(['001_a.sql'], $this->appliedNames(), 'reversed rows removed from tracking');
}
public function testRollbackSkipsMigrationsWithoutADownFile(): void {
$this->markApplied(['001_a.sql', '002_b.sql']);
// No .down.sql written for 002_b.sql.
$result = MigrationRunner::rollback($this->db, ['001_a.sql'], $this->root . '/');
$this->assertSame([], $result['reversed']);
$this->assertSame(['002_b.sql'], $result['skipped']);
$this->assertSame(['001_a.sql', '002_b.sql'], $this->appliedNames(), 'skipped row stays applied');
}
public function testRollbackReturnsEmptyWhenNothingNeedsReversing(): void {
$this->markApplied(['001_a.sql']);
$result = MigrationRunner::rollback($this->db, ['001_a.sql'], $this->root . '/');
$this->assertSame(['reversed' => [], 'skipped' => []], $result);
}
public function testRollbackHandlesMissingMigrationsTable(): void {
// Fresh DB — ensureTable() must create the table without erroring.
$result = MigrationRunner::rollback($this->db, [], $this->root . '/');
$this->assertSame(['reversed' => [], 'skipped' => []], $result);
}
public function testRollbackThrowsWhenADownFileFails(): void {
$this->markApplied(['001_a.sql']);
$this->writeDown('001_a.sql', 'THIS IS NOT VALID SQL;');
$this->expectException(\RuntimeException::class);
MigrationRunner::rollback($this->db, [], $this->root . '/');
}
}