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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user