Files
XC_VM/tests/Unit/ReleaseArchiveInspectorTest.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

73 lines
2.2 KiB
PHP

<?php
use XcVm\Core\Updates\ReleaseArchiveInspector;
use PHPUnit\Framework\TestCase;
/**
* ReleaseArchiveInspector — reading a subpath's file list out of a .tar.gz
* without extracting the whole archive.
*/
final class ReleaseArchiveInspectorTest extends TestCase {
private string $root;
private string $archivePath;
protected function setUp(): void {
if (!class_exists('PharData')) {
$this->markTestSkipped('PharData not available in this runtime.');
}
$this->root = sys_get_temp_dir() . '/xcvm_archive_test_' . uniqid('', true);
mkdir($this->root . '/migrations', 0755, true);
mkdir($this->root . '/other', 0755, true);
file_put_contents($this->root . '/migrations/001_a.sql', 'SELECT 1;');
file_put_contents($this->root . '/migrations/002_b.sql', 'SELECT 2;');
file_put_contents($this->root . '/other/unrelated.txt', 'not a migration');
$this->archivePath = sys_get_temp_dir() . '/xcvm_archive_test_' . uniqid('', true) . '.tar.gz';
$tarPath = substr($this->archivePath, 0, -3); // strip .gz for PharData's own naming
$phar = new PharData($tarPath);
$phar->buildFromDirectory($this->root);
$phar->compress(Phar::GZ, '.tar.gz');
unlink($tarPath);
rename($tarPath . '.gz', $this->archivePath);
}
protected function tearDown(): void {
$this->rrmdir($this->root);
@unlink($this->archivePath);
}
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);
}
public function testListsFilesUnderTheGivenSubpath(): void {
$files = ReleaseArchiveInspector::listSubpathFiles($this->archivePath, 'migrations');
sort($files);
$this->assertSame(['001_a.sql', '002_b.sql'], $files);
}
public function testReturnsEmptyForASubpathTheArchiveDoesNotHave(): void {
$files = ReleaseArchiveInspector::listSubpathFiles($this->archivePath, 'no_such_dir');
$this->assertSame([], $files);
}
public function testThrowsForAMissingArchive(): void {
$this->expectException(\RuntimeException::class);
ReleaseArchiveInspector::listSubpathFiles('/no/such/archive.tar.gz', 'migrations');
}
}