refactor: extract the lit/content-create draft transaction into NewDraft (M7)
NewDraft owns insert (partial write on a throwing hook), refuse and discard (via Rollback::attempt and WriteFailed::*) and the partial logging; ContentCreateAbility keeps checkDivi/prepare/combined (Errors::fieldsOf) and the result. Both discard callers still pass REASON_EXTRAS. Behaviour unchanged; NewDraftTest adds the throwing-delete branch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,14 +7,11 @@ namespace LIT\MCP\Integrations\WordPress\Content;
|
|||||||
use LIT\MCP\Abilities\Ability;
|
use LIT\MCP\Abilities\Ability;
|
||||||
use LIT\MCP\Abilities\Errors;
|
use LIT\MCP\Abilities\Errors;
|
||||||
use LIT\MCP\Abilities\Rev;
|
use LIT\MCP\Abilities\Rev;
|
||||||
use LIT\MCP\Abilities\Rollback;
|
|
||||||
use LIT\MCP\Abilities\ToolResult;
|
use LIT\MCP\Abilities\ToolResult;
|
||||||
use LIT\MCP\Abilities\WriteFailed;
|
|
||||||
use LIT\MCP\ChangeLog\ChangeLog;
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
use LIT\MCP\Integrations\WordPress\PostChangeResult;
|
use LIT\MCP\Integrations\WordPress\PostChangeResult;
|
||||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
use RuntimeException;
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
use WP_Post;
|
use WP_Post;
|
||||||
@@ -25,6 +22,7 @@ use WP_Post;
|
|||||||
* afterwards, created terms and the brand-new draft are deleted again and a WriteFailed
|
* afterwards, created terms and the brand-new draft are deleted again and a WriteFailed
|
||||||
* is thrown (AbilityRegistrar → lit_write_failed, or lit_partial_write if that cleanup
|
* is thrown (AbilityRegistrar → lit_write_failed, or lit_partial_write if that cleanup
|
||||||
* failed or the insert itself threw; partial writes are logged with result "partial").
|
* failed or the insert itself threw; partial writes are logged with result "partial").
|
||||||
|
* The insert and its rollbacks live in NewDraft; this class checks, orders and reports.
|
||||||
*
|
*
|
||||||
* Divi short form (spec §6.2): with `divi` the ContentComposer (Divi integration) checks the whole
|
* Divi short form (spec §6.2): with `divi` the ContentComposer (Divi integration) checks the whole
|
||||||
* description before the insert; the draft is inserted with its normal WordPress fields only
|
* description before the insert; the draft is inserted with its normal WordPress fields only
|
||||||
@@ -33,9 +31,6 @@ use WP_Post;
|
|||||||
*/
|
*/
|
||||||
final class ContentCreateAbility implements Ability
|
final class ContentCreateAbility implements Ability
|
||||||
{
|
{
|
||||||
private const REASON_INSERT = 'WordPress hat beim Anlegen des Eintrags einen Fehler gemeldet.';
|
|
||||||
private const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.';
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ContentSchema $schema,
|
private ContentSchema $schema,
|
||||||
private PostWriter $writer,
|
private PostWriter $writer,
|
||||||
@@ -133,38 +128,31 @@ final class ContentCreateAbility implements Ability
|
|||||||
'post_status' => 'draft',
|
'post_status' => 'draft',
|
||||||
'post_author' => get_current_user_id(),
|
'post_author' => get_current_user_id(),
|
||||||
]);
|
]);
|
||||||
try {
|
$draft = new NewDraft($this->writer, $this->log, $this->name(), $postType);
|
||||||
$postId = wp_insert_post(wp_slash($data), true);
|
$postId = $draft->insert($data);
|
||||||
} catch (Throwable $e) {
|
|
||||||
// A hook threw – the row may or may not exist and its ID is unknown; nothing is
|
|
||||||
// searched or deleted, the client is told to check (lit_partial_write without id).
|
|
||||||
$this->logPartial($postType, '', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden');
|
|
||||||
throw WriteFailed::partial(self::REASON_INSERT, $e);
|
|
||||||
}
|
|
||||||
if ($postId instanceof WP_Error) {
|
if ($postId instanceof WP_Error) {
|
||||||
return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message());
|
return $postId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$postId = (int) $postId;
|
|
||||||
$denied = $this->writer->deniedMeta($postId, $postType, $prepared['fields'], 'add_post_meta');
|
$denied = $this->writer->deniedMeta($postId, $postType, $prepared['fields'], 'add_post_meta');
|
||||||
if ($denied !== []) {
|
if ($denied !== []) {
|
||||||
return $this->refuse($postId, $postType, $denied);
|
return $draft->refuse($postId, $denied);
|
||||||
}
|
}
|
||||||
|
|
||||||
$layout = null;
|
$layout = null;
|
||||||
try {
|
try {
|
||||||
$layout = $withDivi ? $this->divi?->write($postId, $postType, self::sections($input)) : null;
|
$layout = $withDivi ? $this->divi?->write($postId, $postType, self::sections($input)) : null;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->discard($postId, $postType, $e, self::REASON_EXTRAS);
|
$draft->discard($postId, $e, NewDraft::REASON_EXTRAS);
|
||||||
}
|
}
|
||||||
if ($layout instanceof WP_Error) {
|
if ($layout instanceof WP_Error) {
|
||||||
return $this->refuse($postId, $postType, $layout);
|
return $draft->refuse($postId, $layout);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->writer->writeExtras($postId, $postType, $prepared, $input);
|
$this->writer->writeExtras($postId, $postType, $prepared, $input);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->discard($postId, $postType, $e, self::REASON_EXTRAS);
|
$draft->discard($postId, $e, NewDraft::REASON_EXTRAS);
|
||||||
}
|
}
|
||||||
|
|
||||||
$warnings = array_merge($prepared['warnings'], $layout['warnings'] ?? []);
|
$warnings = array_merge($prepared['warnings'], $layout['warnings'] ?? []);
|
||||||
@@ -245,78 +233,4 @@ final class ContentCreateAbility implements Ability
|
|||||||
|
|
||||||
return $result->toArray();
|
return $result->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rolls the brand-new draft back: terms it created, then the draft itself (force –
|
|
||||||
* it cannot have been used anywhere yet). $reason is the client text of the error.
|
|
||||||
*
|
|
||||||
* @throws WriteFailed Always.
|
|
||||||
*/
|
|
||||||
private function discard(int $postId, string $postType, Throwable $cause, string $reason): never
|
|
||||||
{
|
|
||||||
$problems = Rollback::attempt(
|
|
||||||
fn () => $this->writer->deleteCreatedTerms(),
|
|
||||||
static function () use ($postId): void {
|
|
||||||
try {
|
|
||||||
$deleted = wp_delete_post($postId, true);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
sprintf('draft %d could not be deleted: %s', $postId, $e->getMessage()),
|
|
||||||
0,
|
|
||||||
$e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (! $deleted) {
|
|
||||||
throw new RuntimeException(sprintf('draft %d could not be deleted', $postId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$detail = sprintf('Extras of new post %d could not be written: %s', $postId, $cause->getMessage());
|
|
||||||
if ($problems === []) {
|
|
||||||
throw WriteFailed::rolledBack($reason, $cause, $detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
$summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId);
|
|
||||||
$this->logPartial($postType, (string) $postId, $summary);
|
|
||||||
throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Some checks need the draft: meta rights (add_post_meta needs its ID) and the Divi layout
|
|
||||||
* against the new entry's own dynamic content options. A refusal removes the brand-new draft
|
|
||||||
* again and is reported like any invalid input.
|
|
||||||
*
|
|
||||||
* @param array<string, string>|WP_Error $denied fields.<key> => error, or the error itself.
|
|
||||||
* @throws WriteFailed When the draft cannot be deleted.
|
|
||||||
*/
|
|
||||||
private function refuse(int $postId, string $postType, array|WP_Error $denied): WP_Error
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$deleted = (bool) wp_delete_post($postId, true);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$deleted = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $deleted) {
|
|
||||||
$summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId);
|
|
||||||
$this->logPartial($postType, (string) $postId, $summary);
|
|
||||||
$fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied;
|
|
||||||
throw WriteFailed::partial(
|
|
||||||
($denied instanceof WP_Error ? 'Ungültiger Divi-Inhalt: ' : 'Keine Berechtigung für ')
|
|
||||||
. implode(', ', array_keys($fields)) . '.',
|
|
||||||
detail: sprintf('New post %d refused after the insert; draft could not be deleted', $postId),
|
|
||||||
postId: $postId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $denied instanceof WP_Error
|
|
||||||
? $denied
|
|
||||||
: Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function logPartial(string $postType, string $postId, string $summary): void
|
|
||||||
{
|
|
||||||
$this->log->recordPartial($this->name(), $postType, $postId, $summary);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\WordPress\Content;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Abilities\Rollback;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transaction of lit/content-create: inserting the draft and every way back out of it.
|
||||||
|
* The three exits side by side:
|
||||||
|
* - insert(): a throwing hook leaves the draft's existence unknown – partial write without id.
|
||||||
|
* - refuse(): a check that needs the draft failed – the draft is deleted and the refusal is
|
||||||
|
* returned like any invalid input; partial write if the delete fails.
|
||||||
|
* - discard(): writing after the insert failed – created terms and the draft are deleted;
|
||||||
|
* rolled back, or partial write if that cleanup failed.
|
||||||
|
* Partial writes are logged with result "partial".
|
||||||
|
*/
|
||||||
|
final class NewDraft
|
||||||
|
{
|
||||||
|
public const REASON_INSERT = 'WordPress hat beim Anlegen des Eintrags einen Fehler gemeldet.';
|
||||||
|
public const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private PostWriter $writer,
|
||||||
|
private ChangeLog $log,
|
||||||
|
private string $ability,
|
||||||
|
private string $postType
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data wp_insert_post() data (unslashed).
|
||||||
|
* @return int|WP_Error The new draft's ID, or WordPress' refusal as invalid input.
|
||||||
|
* @throws WriteFailed When a hook threw during the insert.
|
||||||
|
*/
|
||||||
|
public function insert(array $data): int|WP_Error
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$postId = wp_insert_post(wp_slash($data), true);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// A hook threw – the row may or may not exist and its ID is unknown; nothing is
|
||||||
|
// searched or deleted, the client is told to check (lit_partial_write without id).
|
||||||
|
$this->logPartial('', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden');
|
||||||
|
throw WriteFailed::partial(self::REASON_INSERT, $e);
|
||||||
|
}
|
||||||
|
if ($postId instanceof WP_Error) {
|
||||||
|
return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $postId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some checks need the draft: meta rights (add_post_meta needs its ID) and the Divi layout
|
||||||
|
* against the new entry's own dynamic content options. A refusal removes the brand-new draft
|
||||||
|
* again and is reported like any invalid input.
|
||||||
|
*
|
||||||
|
* @param array<string, string>|WP_Error $denied fields.<key> => error, or the error itself.
|
||||||
|
* @throws WriteFailed When the draft cannot be deleted.
|
||||||
|
*/
|
||||||
|
public function refuse(int $postId, array|WP_Error $denied): WP_Error
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$deleted = (bool) wp_delete_post($postId, true);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$deleted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $deleted) {
|
||||||
|
$summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId);
|
||||||
|
$this->logPartial((string) $postId, $summary);
|
||||||
|
$fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied;
|
||||||
|
throw WriteFailed::partial(
|
||||||
|
($denied instanceof WP_Error ? 'Ungültiger Divi-Inhalt: ' : 'Keine Berechtigung für ')
|
||||||
|
. implode(', ', array_keys($fields)) . '.',
|
||||||
|
detail: sprintf('New post %d refused after the insert; draft could not be deleted', $postId),
|
||||||
|
postId: $postId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $denied instanceof WP_Error
|
||||||
|
? $denied
|
||||||
|
: Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolls the brand-new draft back: terms it created, then the draft itself (force –
|
||||||
|
* it cannot have been used anywhere yet). $reason is the client text of the error.
|
||||||
|
*
|
||||||
|
* @throws WriteFailed Always.
|
||||||
|
*/
|
||||||
|
public function discard(int $postId, Throwable $cause, string $reason): never
|
||||||
|
{
|
||||||
|
$problems = Rollback::attempt(
|
||||||
|
fn () => $this->writer->deleteCreatedTerms(),
|
||||||
|
static fn () => self::deleteDraft($postId)
|
||||||
|
);
|
||||||
|
|
||||||
|
$detail = sprintf('Extras of new post %d could not be written: %s', $postId, $cause->getMessage());
|
||||||
|
if ($problems === []) {
|
||||||
|
throw WriteFailed::rolledBack($reason, $cause, $detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId);
|
||||||
|
$this->logPartial((string) $postId, $summary);
|
||||||
|
throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws RuntimeException When the draft is not deleted. */
|
||||||
|
private static function deleteDraft(int $postId): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$deleted = wp_delete_post($postId, true);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
throw new RuntimeException(sprintf('draft %d could not be deleted: %s', $postId, $e->getMessage()), 0, $e);
|
||||||
|
}
|
||||||
|
if (! $deleted) {
|
||||||
|
throw new RuntimeException(sprintf('draft %d could not be deleted', $postId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logPartial(string $postId, string $summary): void
|
||||||
|
{
|
||||||
|
$this->log->recordPartial($this->ability, $this->postType, $postId, $summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Content;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class NewDraftTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testADeleteThatThrowsIsNamedInTheLogDetailAndTheOtherStepsStillRun(): void
|
||||||
|
{
|
||||||
|
$writer = Mockery::mock(PostWriter::class);
|
||||||
|
$writer->expects('deleteCreatedTerms')->andThrow(new RuntimeException('term 15 bleibt'));
|
||||||
|
Functions\when('wp_delete_post')->alias(static function (): never {
|
||||||
|
throw new RuntimeException('delete_post hook kaputt');
|
||||||
|
});
|
||||||
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
|
$log->expects('recordPartial')
|
||||||
|
->with('lit/content-create', 'page', '42', 'Entwurf 42: Anlegen nur teilweise zurückgenommen');
|
||||||
|
$draft = new NewDraft($writer, $log, 'lit/content-create', 'page');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$draft->discard(42, new RuntimeException('layout kaputt'), 'Grund für den Client.');
|
||||||
|
self::fail('WriteFailed expected');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertFalse($e->rolledBack);
|
||||||
|
self::assertSame(42, $e->postId);
|
||||||
|
self::assertSame('Grund für den Client.', $e->reason);
|
||||||
|
self::assertSame(
|
||||||
|
'Extras of new post 42 could not be written: layout kaputt; rollback: term 15 bleibt; '
|
||||||
|
. 'draft 42 could not be deleted: delete_post hook kaputt [rollback incomplete]',
|
||||||
|
$e->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testACleanDiscardIsRolledBackWithTheGivenReason(): void
|
||||||
|
{
|
||||||
|
$writer = Mockery::mock(PostWriter::class);
|
||||||
|
$writer->expects('deleteCreatedTerms');
|
||||||
|
Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(true);
|
||||||
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
|
$log->expects('recordPartial')->never();
|
||||||
|
$draft = new NewDraft($writer, $log, 'lit/content-create', 'page');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$draft->discard(42, new RuntimeException('meta kaputt'), NewDraft::REASON_EXTRAS);
|
||||||
|
self::fail('WriteFailed expected');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertTrue($e->rolledBack);
|
||||||
|
self::assertSame(NewDraft::REASON_EXTRAS, $e->reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user