diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index d9735f1..58e3c3d 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -7,14 +7,11 @@ namespace LIT\MCP\Integrations\WordPress\Content; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Rev; -use LIT\MCP\Abilities\Rollback; use LIT\MCP\Abilities\ToolResult; -use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\WordPress\PostChangeResult; use LIT\MCP\Integrations\WordPress\PostGuard; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; -use RuntimeException; use Throwable; use WP_Error; use WP_Post; @@ -25,6 +22,7 @@ use WP_Post; * 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 * 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 * 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 { - 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( private ContentSchema $schema, private PostWriter $writer, @@ -133,38 +128,31 @@ final class ContentCreateAbility implements Ability 'post_status' => 'draft', 'post_author' => get_current_user_id(), ]); - 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($postType, '', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden'); - throw WriteFailed::partial(self::REASON_INSERT, $e); - } + $draft = new NewDraft($this->writer, $this->log, $this->name(), $postType); + $postId = $draft->insert($data); 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'); if ($denied !== []) { - return $this->refuse($postId, $postType, $denied); + return $draft->refuse($postId, $denied); } $layout = null; try { $layout = $withDivi ? $this->divi?->write($postId, $postType, self::sections($input)) : null; } catch (Throwable $e) { - $this->discard($postId, $postType, $e, self::REASON_EXTRAS); + $draft->discard($postId, $e, NewDraft::REASON_EXTRAS); } if ($layout instanceof WP_Error) { - return $this->refuse($postId, $postType, $layout); + return $draft->refuse($postId, $layout); } try { $this->writer->writeExtras($postId, $postType, $prepared, $input); } 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'] ?? []); @@ -245,78 +233,4 @@ final class ContentCreateAbility implements Ability 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|WP_Error $denied fields. => 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); - } } diff --git a/src/Integrations/WordPress/Content/NewDraft.php b/src/Integrations/WordPress/Content/NewDraft.php new file mode 100644 index 0000000..753d7c9 --- /dev/null +++ b/src/Integrations/WordPress/Content/NewDraft.php @@ -0,0 +1,133 @@ + $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|WP_Error $denied fields. => 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); + } +} diff --git a/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php b/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php new file mode 100644 index 0000000..00514aa --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php @@ -0,0 +1,62 @@ +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); + } + } +}