Make the entity repair a console command, not a script

scripts/ is on the exclusion list of build-release.php, so an installation
that runs from the release archive - the usual case on shared hosting -
would never have seen scripts/repair-doubled-entities.php. The 1.20.1 notes
told everyone to run it, which was only true for a Git working copy.

cms:repair:entities does the same work and ships with the application: a dry
run by default, --schreiben to apply, each change validated the way saving
validates it (pages with the heading rule, areas without, as in
PartialRepository). The script is gone; there is one way to do this now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-16 09:05:11 +02:00
1 parent d08d6f648a
commit f4ec0d9877
5 files changed
+262 -130

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Tests;
use App\Command\RepairEntitiesCommand;
use App\Content\DocumentValidator;
use App\Infrastructure\ConnectionFactory;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* `cms:repair:entities` räumt auf, was das doppelte Kodieren vor 1.20.1 hinterlassen hat.
*
* Geprüft wird beides: dass der Trockenlauf nichts anfasst, und dass das Schreiben genau die
* verschachtelten Entities entschachtelt – ein einzelnes `&amp;` ist richtig und muss bleiben.
*/
final class RepairEntitiesCommandTest extends TestCase
{
private string $path;
private Connection $db;
protected function setUp(): void
{
$this->path = tempnam(sys_get_temp_dir(), 'kant-repair-');
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
TestSchema::build($this->db);
$this->db->insert('website', ['id' => 'default', 'name' => 'KantSites', 'default_locale' => 'de', 'theme' => '{}']);
}
protected function tearDown(): void
{
$this->db->close();
gc_collect_cycles();
@unlink($this->path);
}
/** Legt eine Seite mit einem Text an und gibt die Kennung der Fassung zurück. */
private function seite(string $text): string
{
$dokument = [
'schemaVersion' => 17,
'title' => 'Start',
'description' => 'Eine Seite zum Reparieren.',
'blocks' => [['id' => 's1', 'type' => 'section', 'children' => [
['id' => 'h1', 'type' => 'heading', 'props' => ['text' => 'Start', 'level' => 1]],
['id' => 't1', 'type' => 'text', 'props' => ['text' => $text]],
]]],
];
// Erst die Seite, dann ihre Fassung: die Fassung zeigt per Fremdschluessel auf die Seite.
$revision = bin2hex(random_bytes(16));
$this->db->insert('page', ['id' => 'page-1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]);
$this->db->insert('revision', ['id' => $revision, 'page_id' => 'page-1', 'document' => json_encode($dokument, JSON_THROW_ON_ERROR), 'created_at' => gmdate('Y-m-d H:i:s')]);
$this->db->update('page', ['published_revision_id' => $revision, 'draft_revision_id' => $revision], ['id' => 'page-1']);
return $revision;
}
private function text(string $revision): string
{
$dokument = json_decode((string) $this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true, 512, JSON_THROW_ON_ERROR);
return $dokument['blocks'][0]['children'][1]['props']['text'];
}
private function tester(): CommandTester
{
return new CommandTester(new RepairEntitiesCommand($this->db, new DocumentValidator()));
}
public function testTheDryRunReportsWithoutChangingAnything(): void
{
$revision = $this->seite('Freudenstadt &amp;amp; Umgebung');
$tester = $this->tester();
$tester->execute([]);
self::assertStringContainsString('1 Stelle', $tester->getDisplay());
self::assertSame('Freudenstadt &amp;amp; Umgebung', $this->text($revision), 'Ohne --schreiben bleibt alles stehen.');
}
public function testWritingUnnestsEveryLevelAndLeavesASingleEntityAlone(): void
{
// Drei Stufen, wie sie auf einer Seite standen, die dreimal gespeichert wurde.
$revision = $this->seite('Projekte &amp;amp;amp; Lösungen, „Zitat&amp;quot;, und ein richtiges &amp; dazu');
$tester = $this->tester();
$tester->execute(['--schreiben' => true]);
self::assertStringContainsString('repariert', $tester->getDisplay());
self::assertSame('Projekte &amp; Lösungen, „Zitat&quot;, und ein richtiges &amp; dazu', $this->text($revision));
// Ein zweiter Lauf findet nichts mehr - und macht aus dem richtigen &amp; kein nacktes &.
$zweiter = $this->tester();
$zweiter->execute(['--schreiben' => true]);
self::assertStringContainsString('Nichts zu reparieren', $zweiter->getDisplay());
self::assertSame('Projekte &amp; Lösungen, „Zitat&quot;, und ein richtiges &amp; dazu', $this->text($revision));
}
public function testAnUntouchedInstallationSaysSoAndKeepsQuiet(): void
{
$revision = $this->seite('Alles gut &amp; sauber');
$tester = $this->tester();
$tester->execute(['--schreiben' => true]);
self::assertStringContainsString('Nichts zu reparieren', $tester->getDisplay());
self::assertSame('Alles gut &amp; sauber', $this->text($revision));
}
}