Forms gained steps, conditions, image choices, number fields with units and a summary step; the DEEG price finder uses all of it. Plugins are manifest plus Twig: blocks, settings, containers, routes, presets and theme packs, switched per installation. The review backlog is done: archive pages render through the normal page pipeline, forms and post lists work inside areas and sections, repeated sections get their own ids, breakpoints and colours come from the palette, backup and update work on MySQL and lock each other out. The task worker no longer shortens a process that has no time limit: set_time_limit() sets the limit instead of extending it, which turned an unlimited CLI run into eighty seconds and killed the full test suite far away from the scheduler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
338 lines
15 KiB
PHP
338 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Tests;
|
|
|
|
use App\Infrastructure\ConnectionFactory;
|
|
use App\Ops\CommandRunner;
|
|
use App\Ops\Updater;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* The updater against real Git repositories in a temporary directory.
|
|
*
|
|
* Not against this project: an update checks out a different commit, and a test that does that to
|
|
* the working copy it runs in would be a way to lose work. The repositories here are throwaway,
|
|
* and the two commands the updater calls - the console and the migration tool - are tiny stub
|
|
* scripts inside them. That keeps the test about the updater's order and its rollback, while
|
|
* still exercising real git, real file copies and real failures.
|
|
*/
|
|
final class UpdaterTest extends TestCase
|
|
{
|
|
private string $temp;
|
|
private string $work;
|
|
private string $author;
|
|
private CommandRunner $runner;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->runner = new CommandRunner();
|
|
if (!$this->runner->available() || !$this->runner->has('git', sys_get_temp_dir())) {
|
|
self::markTestSkipped('Ohne proc_open und git ist der Updater nicht prüfbar.');
|
|
}
|
|
$this->temp = sys_get_temp_dir().'/kant-update-'.bin2hex(random_bytes(6));
|
|
mkdir($this->temp, 0o775, true);
|
|
$origin = $this->temp.'/origin.git';
|
|
$this->work = $this->temp.'/work';
|
|
$this->author = $this->temp.'/author';
|
|
|
|
$this->git(['init', '--bare', '--initial-branch=main', $origin], $this->temp);
|
|
$this->git(['clone', $origin, $this->author], $this->temp);
|
|
$this->configure($this->author);
|
|
$this->release('1.0.0', true);
|
|
$this->git(['clone', $origin, $this->work], $this->temp);
|
|
$this->configure($this->work);
|
|
|
|
// The installation: a database and a media folder, both outside Git.
|
|
mkdir($this->work.'/var/media', 0o775, true);
|
|
$db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->work.'/var/cms.db'));
|
|
$db->executeStatement('CREATE TABLE revision (id INTEGER PRIMARY KEY, body TEXT)');
|
|
$db->executeStatement("INSERT INTO revision (body) VALUES ('Inhalt von vor dem Update')");
|
|
$db->close();
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$this->remove($this->temp);
|
|
}
|
|
|
|
public function testThePreflightNamesEveryConditionAndWhatIsWrongWithIt(): void
|
|
{
|
|
$checks = $this->updater()->check();
|
|
$names = array_column($checks, 'name');
|
|
self::assertContains('Keine lokalen Änderungen', $names);
|
|
self::assertContains('Datenbank außerhalb von Git', $names);
|
|
self::assertContains('Medien außerhalb von Git', $names);
|
|
self::assertSame([], array_column(array_filter($checks, static fn (array $check): bool => !$check['ok']), 'name'));
|
|
|
|
// A changed file would be lost by a checkout, so the preflight refuses.
|
|
file_put_contents($this->work.'/README.md', 'lokal geändert');
|
|
$dirty = $this->named($this->updater()->check(), 'Keine lokalen Änderungen');
|
|
self::assertFalse($dirty['ok']);
|
|
self::assertStringContainsString('verwerfen', $dirty['detail']);
|
|
|
|
// And a database inside Git would be overwritten by one.
|
|
$this->git(['checkout', '--', 'README.md'], $this->work);
|
|
file_put_contents($this->work.'/.gitignore', '/nichts-besonderes/
|
|
');
|
|
$this->commit($this->work, 'Datenbank nicht mehr ausgenommen');
|
|
$exposed = $this->named($this->updater()->check(), 'Datenbank außerhalb von Git');
|
|
self::assertFalse($exposed['ok']);
|
|
self::assertStringContainsString('überschreiben', $exposed['detail']);
|
|
}
|
|
|
|
public function testAnUpdateMovesToTheNewTagAndLeavesTheSiteOnline(): void
|
|
{
|
|
$this->release('1.1.0', true);
|
|
$updater = $this->updater();
|
|
self::assertSame('1.0.0', $updater->version());
|
|
self::assertSame('v1.1.0', $updater->availableVersion());
|
|
|
|
$result = $updater->update();
|
|
self::assertTrue($result['ok'], $this->explain($result));
|
|
self::assertSame('v1.1.0', $result['target']);
|
|
self::assertSame('1.1.0', $updater->version(), 'The checked out tag is what is installed.');
|
|
self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE), 'A finished update ends the maintenance state.');
|
|
|
|
// The order matters, so it is asserted.
|
|
self::assertSame(
|
|
['Vorprüfung', 'Ziel bestimmen', 'Sperre', 'Sicherung', 'Wartungszustand', 'Code wechseln', 'Fassung prüfen', 'Abhängigkeiten', 'Migration', 'Cache', 'Rauchtest', 'Fertig'],
|
|
array_column($result['steps'], 'step'),
|
|
);
|
|
|
|
// The backup was taken before anything changed, and the media folder is not in it.
|
|
$backups = glob($this->work.'/var/backups/update-*');
|
|
self::assertCount(1, $backups);
|
|
self::assertFileExists($backups[0].'/cms.db');
|
|
self::assertFileDoesNotExist($backups[0].'/media');
|
|
self::assertStringContainsString('Inhalt von vor dem Update', (string) file_get_contents($backups[0].'/cms.db'));
|
|
|
|
// The database and the media folder of the installation are untouched.
|
|
self::assertFileExists($this->work.'/var/cms.db');
|
|
self::assertDirectoryExists($this->work.'/var/media');
|
|
|
|
$log = $updater->log();
|
|
self::assertCount(1, $log);
|
|
self::assertStringContainsString('OK v1.1.0', $log[0]);
|
|
}
|
|
|
|
public function testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings(): void
|
|
{
|
|
file_put_contents($this->author.'/.env', "DATABASE_URL=sqlite:///release-default.db\n");
|
|
$this->release('1.1.0', true);
|
|
$this->git(['fetch', '--tags', 'origin'], $this->work);
|
|
$this->git(['checkout', 'v1.1.0'], $this->work);
|
|
$settings = "DATABASE_URL=sqlite:///customer.db\nAPP_SECRET=customer-secret\n";
|
|
file_put_contents($this->work.'/.env', $settings);
|
|
$this->git(['update-index', '--assume-unchanged', '.env'], $this->work);
|
|
unlink($this->author.'/.env');
|
|
$this->release('1.2.0', true);
|
|
$result = $this->updater()->update();
|
|
self::assertTrue($result['ok'], $this->explain($result));
|
|
self::assertSame($settings, file_get_contents($this->work.'/.env'));
|
|
}
|
|
|
|
public function testAFailingSmokeTestRollsBackTheCodeAndTheDatabase(): void
|
|
{
|
|
// The new version ships a console that fails - as a broken release would.
|
|
$this->release('1.2.0', false);
|
|
$updater = $this->updater();
|
|
|
|
$result = $updater->update();
|
|
self::assertFalse($result['ok']);
|
|
$steps = array_column($result['steps'], 'step');
|
|
self::assertContains('Zurück auf den alten Stand', $steps);
|
|
self::assertContains('Datenbank zurückschreiben', $steps);
|
|
self::assertTrue($this->named($result['steps'], 'Datenbank zurückschreiben', 'step')['ok']);
|
|
// The migration is where it broke, so the database had to go back too.
|
|
self::assertSame('Migration', $this->firstFailed($result)['step']);
|
|
self::assertSame('1.0.0', $updater->version(), 'A failed update leaves the previous version installed.');
|
|
self::assertStringContainsString('Inhalt von vor dem Update', (string) file_get_contents($this->work.'/var/cms.db'));
|
|
// The old version answers, so visitors get the website back rather than a maintenance page.
|
|
self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE));
|
|
self::assertTrue($this->named($result['steps'], 'Wieder online', 'step')['ok']);
|
|
self::assertStringContainsString('FEHLER v1.2.0', $updater->log()[0]);
|
|
}
|
|
|
|
public function testAFailedDatabaseRestoreKeepsTheSiteInMaintenance(): void
|
|
{
|
|
$this->release('1.2.0', false, loseBackup: true);
|
|
$result = $this->updater()->update();
|
|
self::assertFalse($result['ok']);
|
|
self::assertFalse($this->named($result['steps'], 'Datenbank zurückschreiben', 'step')['ok']);
|
|
self::assertFileExists($this->work.'/'.Updater::MAINTENANCE_FILE);
|
|
self::assertNotContains('Wieder online', array_column($result['steps'], 'step'));
|
|
}
|
|
|
|
public function testAnUpdateThatChangesDependenciesStopsWhenComposerIsMissing(): void
|
|
{
|
|
// The installation carries the lock of 1.0.0; the new version brings a different one. On
|
|
// hosting without Composer that cannot be resolved: vendor/ is outside Git and stays old.
|
|
$this->release('1.3.0', true, 'neu');
|
|
|
|
$updater = $this->updater();
|
|
$result = $updater->update();
|
|
|
|
self::assertFalse($result['ok'], $this->explain($result));
|
|
$failed = $this->firstFailed($result);
|
|
self::assertSame('Abhängigkeiten', $failed['step']);
|
|
self::assertStringContainsString('Releasearchiv', $failed['detail']);
|
|
// Rolled back, and the site is online again on the previous version.
|
|
self::assertSame('1.0.0', $updater->version());
|
|
self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE));
|
|
}
|
|
|
|
public function testAnUpdateWithUnchangedDependenciesRunsWithoutComposer(): void
|
|
{
|
|
// Same dependencies as the installed version, so vendor/ stays valid.
|
|
$this->release('1.4.0', true, 'alt');
|
|
|
|
$result = $this->updater()->update();
|
|
self::assertTrue($result['ok'], $this->explain($result));
|
|
self::assertStringContainsString('composer.lock ist unverändert', $this->named($result['steps'], 'Abhängigkeiten', 'step')['detail']);
|
|
}
|
|
|
|
public function testADryRunChangesNothing(): void
|
|
{
|
|
$this->release('1.1.0', true);
|
|
$updater = $this->updater();
|
|
fwrite(STDERR, '
|
|
');
|
|
$result = $updater->update(null, true);
|
|
|
|
self::assertTrue($result['ok'], $this->explain($result));
|
|
self::assertSame('1.0.0', $updater->version());
|
|
self::assertSame([], glob($this->work.'/var/backups/update-*') ?: []);
|
|
self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE));
|
|
self::assertSame([], $updater->log());
|
|
}
|
|
|
|
public function testTheNewestVersionAlreadyInstalledIsNotAFailure(): void
|
|
{
|
|
$updater = $this->updater();
|
|
$result = $updater->update();
|
|
|
|
self::assertTrue($result['ok'], $this->explain($result));
|
|
self::assertSame('Nichts zu tun', end($result['steps'])['step']);
|
|
self::assertSame([], glob($this->work.'/var/backups/update-*') ?: []);
|
|
}
|
|
|
|
public function testAnUnknownTagIsRefusedBeforeAnythingIsTouched(): void
|
|
{
|
|
$updater = $this->updater();
|
|
$result = $updater->update('v9.9.9');
|
|
|
|
self::assertFalse($result['ok']);
|
|
self::assertSame('Code wechseln', $this->firstFailed($result)['step']);
|
|
self::assertSame('1.0.0', $updater->version());
|
|
// The maintenance state was switched on before the checkout, and the rollback ends it.
|
|
self::assertFalse(is_file($this->work.'/'.Updater::MAINTENANCE_FILE));
|
|
}
|
|
|
|
private function updater(): Updater
|
|
{
|
|
$db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->work.'/var/cms.db'));
|
|
|
|
return new Updater(
|
|
str_replace('\\', '/', $this->work),
|
|
'sqlite:///'.str_replace('\\', '/', $this->work.'/var/cms.db'),
|
|
$this->runner,
|
|
$db,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Publishes a release: VERSION, a .gitignore that keeps the installation out of Git, and the
|
|
* two stub commands the updater calls. `$healthy` decides whether they succeed.
|
|
*/
|
|
private function release(string $version, bool $healthy, string $lock = 'alt', bool $loseBackup = false): void
|
|
{
|
|
file_put_contents($this->author.'/composer.lock', json_encode(['packages' => [$lock]]));
|
|
file_put_contents($this->author.'/VERSION', $version."\n");
|
|
file_put_contents($this->author.'/README.md', 'KantSites '.$version."\n");
|
|
// Only the installation is kept out of Git here: the stub commands live under vendor/
|
|
// and travel with the tag, the way vendor/ does on a host without Composer.
|
|
file_put_contents($this->author.'/.gitignore', '/var/
|
|
');
|
|
foreach (['bin', 'vendor/bin'] as $directory) {
|
|
if (!is_dir($this->author.'/'.$directory)) {
|
|
mkdir($this->author.'/'.$directory, 0o775, true);
|
|
}
|
|
}
|
|
// Stubs, not the real console: this test is about the updater's order and its rollback.
|
|
$stub = $healthy
|
|
? "<?php\nfwrite(STDOUT, 'OK '.implode(' ', array_slice(\$argv, 1)).PHP_EOL);\nexit(0);\n"
|
|
: "<?php\nfile_put_contents(__DIR__.'/../../var/cms.db', 'Unvollständige Migration');\nfwrite(STDOUT, 'Migration gescheitert'.PHP_EOL);\nexit(1);\n";
|
|
file_put_contents($this->author.'/bin/console', $healthy ? $stub : "<?php\nfwrite(STDOUT, 'OK'.PHP_EOL);\nexit(0);\n");
|
|
if ($loseBackup) {
|
|
$stub = str_replace('exit(1);', "foreach (glob(__DIR__.'/../../var/backups/update-*/cms.db') ?: [] as \$backup) { unlink(\$backup); } exit(1);", $stub);
|
|
}
|
|
file_put_contents($this->author.'/vendor/bin/doctrine-migrations', $stub);
|
|
$this->commit($this->author, 'Fassung '.$version);
|
|
$this->git(['tag', 'v'.$version], $this->author);
|
|
$this->git(['push', '--quiet', 'origin', 'main', '--tags'], $this->author);
|
|
}
|
|
|
|
private function configure(string $directory): void
|
|
{
|
|
$this->git(['config', 'user.email', 'test@example.invalid'], $directory);
|
|
$this->git(['config', 'user.name', 'Updater Test'], $directory);
|
|
}
|
|
|
|
private function commit(string $directory, string $message): void
|
|
{
|
|
$this->git(['add', '-A'], $directory);
|
|
$this->git(['commit', '--quiet', '-m', $message], $directory);
|
|
}
|
|
|
|
private function git(array $arguments, string $cwd): void
|
|
{
|
|
$result = $this->runner->run(['git', ...$arguments], $cwd, 60);
|
|
self::assertTrue($result['ok'], 'git '.implode(' ', $arguments).': '.$result['output']);
|
|
}
|
|
|
|
/** @param list<array<string, mixed>> $entries */
|
|
private function named(array $entries, string $needle, string $key = 'name'): array
|
|
{
|
|
foreach ($entries as $entry) {
|
|
if ($needle === $entry[$key]) {
|
|
return $entry;
|
|
}
|
|
}
|
|
self::fail(sprintf('Kein Eintrag „%s“ vorhanden: %s', $needle, implode(', ', array_column($entries, $key))));
|
|
}
|
|
|
|
private function firstFailed(array $result): array
|
|
{
|
|
foreach ($result['steps'] as $step) {
|
|
if (!$step['ok']) {
|
|
return $step;
|
|
}
|
|
}
|
|
self::fail('Kein gescheiterter Schritt: '.$this->explain($result));
|
|
}
|
|
|
|
private function explain(array $result): string
|
|
{
|
|
return implode("\n", array_map(
|
|
static fn (array $step): string => sprintf('%s %s: %s', $step['ok'] ? 'ok' : 'FEHLER', $step['step'], $step['detail']),
|
|
$result['steps'],
|
|
));
|
|
}
|
|
|
|
private function remove(string $path): void
|
|
{
|
|
if (!is_dir($path)) {
|
|
@unlink($path);
|
|
|
|
return;
|
|
}
|
|
foreach (scandir($path) ?: [] as $entry) {
|
|
if ('.' !== $entry && '..' !== $entry) {
|
|
@chmod($path.'/'.$entry, 0o777);
|
|
$this->remove($path.'/'.$entry);
|
|
}
|
|
}
|
|
@rmdir($path);
|
|
}
|
|
}
|