fix: global library edits check private and scheduled pages, name the backup on failure (P8a, P14)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 01:25:16 +02:00
co-authored by Claude Opus 5.5
parent d66ad36c2e
commit 520a639411
7 changed files with 332 additions and 45 deletions
+23 -1
View File
@@ -32,7 +32,7 @@ final class WriteFailed extends RuntimeException
public readonly ?int $postId, public readonly ?int $postId,
public readonly ?int $revisionId, public readonly ?int $revisionId,
?Throwable $previous, ?Throwable $previous,
string $detail, private string $detail,
public readonly ?string $state, public readonly ?string $state,
public readonly ?string $hint, public readonly ?string $hint,
public readonly array $details public readonly array $details
@@ -82,6 +82,28 @@ final class WriteFailed extends RuntimeException
return new self($reason, false, $postId, $revisionId, $cause, $detail, $state, $hint, $details); return new self($reason, false, $postId, $revisionId, $cause, $detail, $state, $hint, $details);
} }
/**
* The same failure with $details merged into its client details; a non-null $hint or $state
* replaces the current one (see rolledBack()/partial()). For callers that learn about
* something kept (e.g. a backup) only after the failure was thrown.
*
* @param array<string, mixed> $details
*/
public function withDetails(array $details, ?string $hint = null, ?string $state = null): self
{
return new self(
$this->reason,
$this->rolledBack,
$this->postId,
$this->revisionId,
$this->getPrevious(),
$this->detail,
$state ?? $this->state,
$hint ?? $this->hint,
array_merge($this->details, $details)
);
}
public function toError(): WP_Error public function toError(): WP_Error
{ {
return $this->rolledBack return $this->rolledBack
+84 -21
View File
@@ -27,7 +27,8 @@ defined('ABSPATH') || exit;
* save scope is GlobalItem (site-wide cache) and, when revisions are off for the entry, a backup * save scope is GlobalItem (site-wide cache) and, when revisions are off for the entry, a backup
* copy is made first – the revision itself is DiviSaver's restore point, as for every page. * copy is made first – the revision itself is DiviSaver's restore point, as for every page.
* Confirm stays honour-based (ruling P9, like D10). A confirmed edit is refused while the user may * Confirm stays honour-based (ruling P9, like D10). A confirmed edit is refused while the user may
* not edit_post one of the published pages that show the entry (ruling P8, stricter than Divi). * not edit_post one of the published, private or scheduled posts that show the entry (rulings
* P8/P8a, stricter than Divi); the dry run names them already (blocked_ids).
* The backup (PostCloner, wp_insert_post with kses) is refused when it could not be faithful: for * The backup (PostCloner, wp_insert_post with kses) is refused when it could not be faithful: for
* users without unfiltered_html, and when the trash is disabled (ruling P9, S37). * users without unfiltered_html, and when the trash is disabled (ruling P9, S37).
*/ */
@@ -90,6 +91,7 @@ final class GlobalEdits
$usages = $this->usages->of($id); $usages = $this->usages->of($id);
$details = ['affected' => $usages['items'], 'affected_count' => $usages['count']]; $details = ['affected' => $usages['items'], 'affected_count' => $usages['count']];
$blocked = $this->blocked($id);
if (($input['confirm'] ?? false) !== true) { if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired( return Errors::confirmRequired(
sprintf( sprintf(
@@ -97,13 +99,13 @@ final class GlobalEdits
. 'veröffentlichte. Nichts wurde gespeichert (Probelauf).', . 'veröffentlichte. Nichts wurde gespeichert (Probelauf).',
$id, $id,
$usages['count'], $usages['count'],
$usages['count'] === 1 ? 'Seite' : 'Seiten' self::pages($usages['count'])
), ) . self::blockedNote($blocked),
$details $details + self::blockedDetails($blocked)
); );
} }
$refusal = $this->pagesRefusal($id) ?? $this->backupRefusal($item); $refusal = $this->pagesRefusal($id, $blocked) ?? $this->backupRefusal($item);
if ($refusal !== null) { if ($refusal !== null) {
return $refusal; return $refusal;
} }
@@ -125,44 +127,105 @@ final class GlobalEdits
return $error; return $error;
} }
$usages = $this->usages->of((int) $page->post->ID); $id = (int) $page->post->ID;
$usages = $this->usages->of($id);
$blocked = $this->blocked($id);
$data = $error->get_error_data(); $data = $error->get_error_data();
$details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : []; $details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : [];
return Errors::confirmRequired( return Errors::confirmRequired(
Errors::clientMessage($error) Errors::clientMessage($error)
. sprintf(' Globaler Bibliothekseintrag: wirkt auf %d Seiten (siehe affected).', $usages['count']), . sprintf(
array_merge($details, ['affected' => $usages['items'], 'affected_count' => $usages['count']]) ' Globaler Bibliothekseintrag: wirkt auf %d %s (siehe affected).',
$usages['count'],
self::pages($usages['count'])
)
. self::blockedNote($blocked),
array_merge(
$details,
['affected' => $usages['items'], 'affected_count' => $usages['count']],
self::blockedDetails($blocked)
)
); );
} }
/** Ruling P8: every published page that shows entry $id must be editable by the user. */ /**
private function pagesRefusal(int $id): ?WP_Error * Ruling P8a: the live posts (published, private, scheduled – they render now or will) that show
* entry $id and that the user may not edit_post. Drafts and pending posts never block.
*
* @return list<int>
*/
private function blocked(int $id): array
{ {
$blocked = array_values(array_filter( $live = $this->usages->liveIds($id);
$this->usages->publishedIds($id), if ($live === []) {
static fn (int $pageId): bool => ! current_user_can('edit_post', $pageId) return [];
}
_prime_post_caches($live, false, false);
return array_values(array_filter(
$live,
static fn (int $postId): bool => ! current_user_can('edit_post', $postId)
)); ));
}
/**
* Ruling P8a: a confirmed edit needs edit_post on every live post that shows entry $id.
*
* @param list<int> $blocked
*/
private function pagesRefusal(int $id, array $blocked): ?WP_Error
{
if ($blocked === []) { if ($blocked === []) {
return null; return null;
} }
$listed = implode(', ', array_slice($blocked, 0, self::LISTED_IDS));
$more = count($blocked) - self::LISTED_IDS;
return Errors::forbidden( return Errors::forbidden(
sprintf( sprintf(
'Globaler Bibliothekseintrag %d erscheint auf veröffentlichten Einträgen, die dieses Konto nicht ' 'Globaler Bibliothekseintrag %d erscheint auf veröffentlichten, privaten oder geplanten Einträgen, '
. 'bearbeiten darf (IDs %s%s); nichts wurde gespeichert.', . 'die dieses Konto nicht bearbeiten darf (IDs %s); nichts wurde gespeichert.',
$id, $id,
$listed, self::listed($blocked)
$more > 0 ? sprintf(' und %d weitere', $more) : ''
), ),
'Die Änderung von jemandem vornehmen lassen, der alle diese Seiten bearbeiten darf (z. B. Redakteur ' 'Die Änderung von jemandem vornehmen lassen, der alle diese Einträge bearbeiten darf (z. B. Redakteur '
. 'oder Administrator).' . 'oder Administrator).'
); );
} }
/** @param list<int> $blocked */
private static function blockedNote(array $blocked): string
{
return $blocked === [] ? '' : sprintf(
' Achtung: Die Bestätigung würde abgelehnt – veröffentlichte, private oder geplante Einträge, die dieses '
. 'Konto nicht bearbeiten darf, zeigen den Eintrag (IDs %s, siehe blocked_ids).',
self::listed($blocked)
);
}
/**
* @param list<int> $blocked
* @return array<string, list<int>>
*/
private static function blockedDetails(array $blocked): array
{
return $blocked === [] ? [] : ['blocked_ids' => $blocked];
}
/** @param list<int> $ids At most LISTED_IDS of them, then "und N weitere". */
private static function listed(array $ids): string
{
$more = count($ids) - self::LISTED_IDS;
return implode(', ', array_slice($ids, 0, self::LISTED_IDS))
. ($more > 0 ? sprintf(' und %d weitere', $more) : '');
}
private static function pages(int $count): string
{
return $count === 1 ? 'Seite' : 'Seiten';
}
/** Ruling P9: a backup (only made when revisions are off) must be faithful and must survive. */ /** Ruling P9: a backup (only made when revisions are off) must be faithful and must survive. */
private function backupRefusal(WP_Post $item): ?WP_Error private function backupRefusal(WP_Post $item): ?WP_Error
{ {
+23 -12
View File
@@ -15,8 +15,11 @@ class GlobalUsages
{ {
public const LIMIT = 50; public const LIMIT = 50;
private const WHERE = "post_content LIKE %s AND post_type <> 'revision' " /** Every post that exists for the user: listed in the dry run. */
. "AND post_status NOT IN ('trash', 'auto-draft', 'inherit')"; private const LISTED = "post_status NOT IN ('trash', 'auto-draft', 'inherit')";
/** Posts that render now or will on their own: published, private, scheduled (ruling P8a). */
private const LIVE = "post_status IN ('publish', 'private', 'future')";
public function __construct(private ?\wpdb $db = null) public function __construct(private ?\wpdb $db = null)
{ {
@@ -29,12 +32,12 @@ class GlobalUsages
$like = self::like($db, $libraryId); $like = self::like($db, $libraryId);
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
$count = (int) $db->get_var( $count = (int) $db->get_var(
$db->prepare('SELECT COUNT(*) FROM ' . $db->posts . ' WHERE ' . self::WHERE, $like) $db->prepare('SELECT COUNT(*) FROM ' . $db->posts . ' WHERE ' . self::where(self::LISTED), $like)
); );
$rows = $db->get_results( $rows = $db->get_results(
$db->prepare( $db->prepare(
'SELECT ID, post_title, post_status, post_type FROM ' . $db->posts . ' WHERE ' . self::WHERE 'SELECT ID, post_title, post_status, post_type FROM ' . $db->posts . ' WHERE '
. ' ORDER BY ID ASC LIMIT %d', . self::where(self::LISTED) . ' ORDER BY ID ASC LIMIT %d',
$like, $like,
self::LIMIT self::LIMIT
), ),
@@ -57,18 +60,17 @@ class GlobalUsages
} }
/** /**
* IDs of every published post that shows $libraryId – no limit (ruling P8 checks edit_post on * IDs of every live post (published, private, scheduled) that shows $libraryId – no limit
* each of them before a confirmed global edit). * (ruling P8a checks edit_post on each of them before a confirmed global edit).
* *
* @return list<int> * @return list<int>
*/ */
public function publishedIds(int $libraryId): array public function liveIds(int $libraryId): array
{ {
$db = $this->db(); $db = $this->db();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
$ids = $db->get_col($db->prepare( $ids = $db->get_col($db->prepare(
'SELECT ID FROM ' . $db->posts . " WHERE post_content LIKE %s AND post_type <> 'revision' " 'SELECT ID FROM ' . $db->posts . ' WHERE ' . self::where(self::LIVE) . ' ORDER BY ID ASC',
. "AND post_status = 'publish' ORDER BY ID ASC",
self::like($db, $libraryId) self::like($db, $libraryId)
)); ));
// phpcs:enable // phpcs:enable
@@ -76,13 +78,22 @@ class GlobalUsages
return array_values(array_map('intval', is_array($ids) ? $ids : [])); return array_values(array_map('intval', is_array($ids) ? $ids : []));
} }
private function db(): \wpdb /**
* The one condition of both queries: posts (no revisions) whose content holds an instance
* (placeholder %s = like()), with the fixed status clause $status (a class constant).
*/
private static function where(string $status): string
{ {
return $this->db ?? $GLOBALS['wpdb']; return "post_content LIKE %s AND post_type <> 'revision' AND " . $status;
} }
private static function like(\wpdb $db, int $libraryId): string private static function like(\wpdb $db, int $libraryId): string
{ {
return '%' . $db->esc_like('"globalModule":"' . $libraryId . '"') . '%'; return '%' . $db->esc_like('"globalModule":"' . $libraryId . '"') . '%';
} }
private function db(): \wpdb
{
return $this->db ?? $GLOBALS['wpdb'];
}
} }
+37 -1
View File
@@ -179,7 +179,43 @@ final class DiviWriter
return $prepared; return $prepared;
} }
return array_merge($this->save($ability, $page, $change, $input, $prepared['scope']), $prepared['details']); try {
$saved = $this->save($ability, $page, $change, $input, $prepared['scope']);
} catch (WriteFailed $e) {
throw self::keepingBackup($e, $prepared['details']['backup_id'] ?? null);
}
return array_merge($saved, $prepared['details']);
}
/**
* Ruling P14: GlobalEdits made a backup (revisions off) before the save failed – the error names
* it, because it stays in the trash and is the only earlier state.
*/
private static function keepingBackup(WriteFailed $e, mixed $backupId): WriteFailed
{
if (! is_int($backupId)) {
return $e;
}
$kept = sprintf(
'Sicherung %d liegt im Papierkorb (vorheriger Stand des Bibliothekseintrags, in WordPress '
. 'wiederherstellbar).',
$backupId
);
if ($e->rolledBack) {
return $e->withDetails(
['backup_id' => $backupId],
$kept . ' Den Aufruf erst nach Prüfung mit lit-divi-outline wiederholen.',
Errors::notSavedKeeping(sprintf('Sicherung %d im Papierkorb', $backupId))
);
}
return $e->withDetails(
['backup_id' => $backupId],
'Den Aufruf nicht einfach wiederholen. ' . $kept . ' Zuerst den Stand mit lit-divi-outline prüfen und '
. 'dem Nutzer Bescheid geben.'
);
} }
/** A tool's confirm_required on a global library entry, extended by the affected pages. */ /** A tool's confirm_required on a global library entry, extended by the affected pages. */
+25
View File
@@ -96,4 +96,29 @@ final class WriteFailedTest extends TestCase
self::assertSame([7, 811], [$partial->postId, $partial->revisionId]); self::assertSame([7, 811], [$partial->postId, $partial->revisionId]);
self::assertArrayNotHasKey('post_id', $clean->toError()->get_error_data()); self::assertArrayNotHasKey('post_id', $clean->toError()->get_error_data());
} }
/** Ruling P14: details (and hint/state) added after the fact, everything else kept. */
public function testWithDetailsAddsDetailsAndKeepsTheRest(): void
{
$cause = new RuntimeException('hook');
$failure = WriteFailed::rolledBack('x', $cause, 'intern', details: ['a' => 1], postId: 5, revisionId: 811);
$extended = $failure->withDetails(['backup_id' => 9], 'Sicherung 9 liegt im Papierkorb.', 'Bleibt.');
self::assertNotSame($failure, $extended);
self::assertTrue($extended->rolledBack);
self::assertSame($cause, $extended->getPrevious());
self::assertSame($failure->getMessage(), $extended->getMessage());
self::assertSame([5, 811], [$extended->postId, $extended->revisionId]);
$data = $extended->toError()->get_error_data();
self::assertSame(1, $data['a']);
self::assertSame(9, $data['backup_id']);
self::assertSame('Sicherung 9 liegt im Papierkorb.', $data['hint']);
self::assertStringContainsString('Bleibt.', $extended->toError()->get_error_message());
$partial = WriteFailed::partial('x', postId: 5)->withDetails(['backup_id' => 9]);
self::assertFalse($partial->rolledBack);
self::assertSame(9, $partial->toError()->get_error_data()['backup_id']);
self::assertSame(5, $partial->toError()->get_error_data()['post_id']);
}
} }
@@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev; use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Library\GlobalEdits; use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
@@ -17,6 +18,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\SaveScope; use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Tests\Support\DiviFixtures; use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture; use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\LibraryFixtures; use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use Mockery; use Mockery;
@@ -30,8 +32,14 @@ final class GlobalEditsTest extends TestCase
private int $backups = 0; private int $backups = 0;
/** @var list<int> Published pages that show the global entry (ruling P8). */ /** Change log of the writer; null = DiviWriterFixture::log() (records only). */
private array $published = [1053]; private ?FakeChangeLog $log = null;
/** Thrown by the saver instead of saving, if set. */
private ?\Throwable $failure = null;
/** @var list<int> Live (published, private, scheduled) posts that show the global entry (ruling P8a). */
private array $live = [1053];
private bool $trash = true; private bool $trash = true;
@@ -39,6 +47,7 @@ final class GlobalEditsTest extends TestCase
{ {
parent::setUp(); parent::setUp();
Functions\when('wp_revisions_enabled')->justReturn(true); Functions\when('wp_revisions_enabled')->justReturn(true);
Functions\when('_prime_post_caches')->justReturn(null);
} }
private function usages(): GlobalUsages private function usages(): GlobalUsages
@@ -51,7 +60,7 @@ final class GlobalEditsTest extends TestCase
], ],
'count' => 2, 'count' => 2,
]); ]);
$usages->allows('publishedIds')->andReturnUsing(fn (): array => $this->published); $usages->allows('liveIds')->andReturnUsing(fn (): array => $this->live);
return $usages; return $usages;
} }
@@ -61,6 +70,9 @@ final class GlobalEditsTest extends TestCase
{ {
$saver = Mockery::mock(DiviSaver::class); $saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array { $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array {
if ($this->failure !== null) {
throw $this->failure;
}
$this->saved[] = [$content, $scope]; $this->saved[] = [$content, $scope];
$post->post_content = $content; $post->post_content = $content;
@@ -82,7 +94,7 @@ final class GlobalEditsTest extends TestCase
return new DiviWriter( return new DiviWriter(
LibraryFixtures::loader($access), LibraryFixtures::loader($access),
$saver, $saver,
DiviWriterFixture::log(), $this->log ?? DiviWriterFixture::log(),
$design, $design,
DiviWriterFixture::composition($design), DiviWriterFixture::composition($design),
$globals $globals
@@ -209,20 +221,138 @@ final class GlobalEditsTest extends TestCase
public function testConfirmIsRefusedWhenAPublishedPageShowingTheEntryIsNotEditable(): void public function testConfirmIsRefusedWhenAPublishedPageShowingTheEntryIsNotEditable(): void
{ {
LibraryFixtures::given([LibraryFixtures::item(1059)]); LibraryFixtures::given([LibraryFixtures::item(1059)]);
$this->published = [1053, 1080]; $this->live = [1053, 1080];
self::denyOn('edit_post', [1080]); self::denyOn('edit_post', [1080]);
$dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059)); $dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true)); $result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertToolError($dryRun, 'confirm_required', 'the dry run stays unchanged'); self::assertToolError($dryRun, 'confirm_required', 'the dry run still lists the pages');
self::assertToolError($result, 'forbidden'); self::assertToolError($result, 'forbidden');
self::assertStringContainsString('1080', $result->get_error_message()); self::assertStringContainsString('1080', $result->get_error_message());
self::assertStringContainsString('veröffentlichten, privaten oder geplanten', $result->get_error_message());
self::assertStringNotContainsString('1053', $result->get_error_message()); self::assertStringNotContainsString('1053', $result->get_error_message());
self::assertSame([], $this->saved); self::assertSame([], $this->saved);
self::assertSame(0, $this->backups); self::assertSame(0, $this->backups);
} }
/**
* Ruling P8a: private and scheduled posts render (or will) – each blocks when the user may not
* edit it; drafts are listed in the dry run but never block (they are not among liveIds()).
*/
public function testPrivateAndScheduledPagesBlockButDraftsDoNot(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
foreach ([1081, 1082] as $private) {
$this->live = [1053, $private];
self::denyOn('edit_post', [$private, 1070]);
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertToolError($result, 'forbidden');
self::assertStringContainsString((string) $private, $result->get_error_message());
}
self::assertSame([], $this->saved);
$this->live = [1053];
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::assertTrue($result['ok'], 'draft 1070 (not editable) does not block');
}
/** Ruling P14 (M-1): the dry run already says which posts would block the confirm. */
public function testTheDryRunNamesThePostsThatWouldBlock(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$this->live = [1053, 1081];
self::denyOn('edit_post', [1081]);
$dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));
self::assertToolError($dryRun, 'confirm_required');
self::assertSame([1081], $dryRun->get_error_data()['blocked_ids']);
self::assertStringContainsString('1081', $dryRun->get_error_message());
self::assertStringContainsString('abgelehnt', $dryRun->get_error_message());
}
public function testADryRunWithoutBlockersHasNoBlockedIds(): void
{
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$dryRun = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));
self::assertArrayNotHasKey('blocked_ids', $dryRun->get_error_data());
}
/** Ruling P14 (I-2): a failed save after the backup names it. */
public function testAFailedSaveAfterTheBackupNamesIt(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$this->failure = WriteFailed::rolledBack(DiviSaver::REASON, new \RuntimeException('hook'), postId: 1059);
try {
(new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
$error = $e->toError();
}
self::assertTrue($e->rolledBack);
self::assertSame(1071, $error->get_error_data()['backup_id']);
self::assertStringContainsString('Sicherung 1071 liegt im Papierkorb', $error->get_error_data()['hint']);
self::assertStringNotContainsString('Nichts wurde gespeichert', $error->get_error_message());
self::assertSame(1, $this->backups);
}
public function testAPartialSaveAfterTheBackupNamesIt(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
LibraryFixtures::given([LibraryFixtures::item(1059)]);
$this->failure = WriteFailed::partial(DiviSaver::REASON, new \RuntimeException('hook'), postId: 1059);
$this->log = FakeChangeLog::partialsOnly();
try {
(new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
$error = $e->toError();
}
self::assertToolError($error, 'partial_write');
self::assertSame(1071, $error->get_error_data()['backup_id']);
self::assertSame(1059, $error->get_error_data()['post_id']);
self::assertStringContainsString('Sicherung 1071 liegt im Papierkorb', $error->get_error_data()['hint']);
self::assertCount(1, $this->log->partials);
}
/** Ruling P14 (M-2): one page is "Seite". */
public function testDeleteNoticeUsesTheSingularForOnePage(): void
{
LibraryFixtures::given([LibraryFixtures::item(1058)]);
$usages = Mockery::mock(GlobalUsages::class);
$usages->allows('of')->andReturn([
'items' => [['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page']],
'count' => 1,
]);
$usages->allows('liveIds')->andReturn([1053]);
$access = LibraryFixtures::access();
$design = DiviWriterFixture::designReader();
$writer = new DiviWriter(
LibraryFixtures::loader($access),
Mockery::mock(DiviSaver::class),
DiviWriterFixture::log(),
$design,
DiviWriterFixture::composition($design),
new GlobalEdits($access, $usages)
);
$result = (new DiviDeleteAbility($writer))->execute(
['post_id' => 1058, 'tree_rev' => self::rev(1058), 'ids' => ['s0.r0.c0.m1']]
);
self::assertStringContainsString('wirkt auf 1 Seite (siehe affected)', $result->get_error_message());
}
public function testRefusesToEmptyOrDoubleTheRoot(): void public function testRefusesToEmptyOrDoubleTheRoot(): void
{ {
LibraryFixtures::given([LibraryFixtures::item(1058)]); LibraryFixtures::given([LibraryFixtures::item(1058)]);
@@ -45,8 +45,8 @@ final class GlobalUsagesTest extends TestCase
self::assertStringContainsString('LIMIT 50', $queries[1]); self::assertStringContainsString('LIMIT 50', $queries[1]);
} }
/** Ruling P8: every published page showing the entry, beyond the listing limit. */ /** Ruling P8a: every live (published, private, scheduled) post showing the entry, beyond the listing limit. */
public function testPublishedIdsAreUnlimited(): void public function testLiveIdsAreUnlimited(): void
{ {
$db = Mockery::mock(\wpdb::class)->makePartial(); $db = Mockery::mock(\wpdb::class)->makePartial();
$queries = []; $queries = [];
@@ -56,9 +56,9 @@ final class GlobalUsagesTest extends TestCase
return ['1053', '1080']; return ['1053', '1080'];
}); });
self::assertSame([1053, 1080], (new GlobalUsages($db))->publishedIds(1059)); self::assertSame([1053, 1080], (new GlobalUsages($db))->liveIds(1059));
self::assertStringContainsString("LIKE '%\"globalModule\":\"1059\"%'", $queries[0]); self::assertStringContainsString("LIKE '%\"globalModule\":\"1059\"%'", $queries[0]);
self::assertStringContainsString("post_status = 'publish'", $queries[0]); self::assertStringContainsString("post_status IN ('publish', 'private', 'future')", $queries[0]);
self::assertStringContainsString("post_type <> 'revision'", $queries[0]); self::assertStringContainsString("post_type <> 'revision'", $queries[0]);
self::assertStringNotContainsString('LIMIT', $queries[0]); self::assertStringNotContainsString('LIMIT', $queries[0]);
} }