diff --git a/src/Abilities/Rev.php b/src/Abilities/Rev.php index 657e447..8b1ca36 100644 --- a/src/Abilities/Rev.php +++ b/src/Abilities/Rev.php @@ -4,13 +4,32 @@ declare(strict_types=1); namespace LIT\MCP\Abilities; +use WP_Post; + /** - * Short hash of post_content – the optimistic-lock handle ("rev", spec §5.3). + * Short hash used as optimistic-lock handle ("rev", spec §5.3). */ final class Rev { + /** Hash of a single string, e.g. post_content (Divi layouts). */ public static function of(string $content): string { return substr(hash('sha256', $content), 0, 12); } + + /** + * Rev of an entry for the content tools: changes when content, title, excerpt or + * status change, or when anything else touched the post row (post_modified_gmt), + * so parallel edits beyond post_content are detected as well. + */ + public static function ofPost(WP_Post $post): string + { + return self::of(implode("\0", [ + (string) $post->post_content, + (string) $post->post_title, + (string) $post->post_excerpt, + (string) $post->post_status, + (string) $post->post_modified_gmt, + ])); + } } diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php new file mode 100644 index 0000000..360b1b3 --- /dev/null +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -0,0 +1,151 @@ + 'object', + 'properties' => array_merge([ + 'post_type' => ['type' => 'string'], + 'title' => ['type' => 'string', 'minLength' => 1], + ], PostWriter::inputProperties()), + 'required' => ['post_type', 'title'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'create_posts'); + + return $type instanceof WP_Error ? $type : true; + } + + public function execute(array $input): mixed + { + $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'create_posts'); + if ($type instanceof WP_Error) { + return $type; + } + + $postType = (string) $input['post_type']; + $prepared = $this->writer->prepare($postType, $input, true); + if ($prepared instanceof WP_Error) { + return $prepared; + } + + $data = array_merge($this->writer->postData($input), [ + 'post_type' => $postType, + 'post_status' => 'draft', + 'post_author' => get_current_user_id(), + ]); + $postId = wp_insert_post(wp_slash($data), true); + if ($postId instanceof WP_Error) { + return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message()); + } + + $postId = (int) $postId; + try { + $this->writer->writeExtras($postId, $postType, $prepared, $input); + } catch (Throwable $e) { + $this->discard($postId, $e); + } + do_action('lit_mcp_after_save', $postId, $this->name(), $input); + + $post = get_post($postId); + $title = $post instanceof WP_Post ? (string) $post->post_title : (string) $input['title']; + $summary = sprintf('Entwurf "%s" angelegt', $title); + $logId = $this->log->record($this->name(), $postType, (string) $postId, $summary); + + return ToolResult::ok(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId)) + ->changed((string) $postId, $postType, 'created', $title) + ->rev($post instanceof WP_Post ? Rev::ofPost($post) : null) + ->logId($logId) + ->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw')) + ->warnings($prepared['warnings']) + ->toArray(); + } + + /** + * Deletes the draft this call just created (it cannot have been used anywhere yet). + * + * @throws RuntimeException Always – the original failure, noting whether the draft is gone. + */ + private function discard(int $postId, Throwable $cause): never + { + $deleted = wp_delete_post($postId, true); + + throw new RuntimeException( + sprintf( + 'Extras of new post %d could not be written (%s); draft %s.', + $postId, + $cause->getMessage(), + $deleted ? 'deleted again' : 'could NOT be deleted' + ), + 0, + $cause + ); + } +} diff --git a/src/Integrations/WordPress/Content/ContentUpdateAbility.php b/src/Integrations/WordPress/Content/ContentUpdateAbility.php new file mode 100644 index 0000000..d62349f --- /dev/null +++ b/src/Integrations/WordPress/Content/ContentUpdateAbility.php @@ -0,0 +1,244 @@ + 'object', + 'properties' => array_merge([ + 'post_id' => ['type' => 'integer', 'minimum' => 1], + 'rev' => ['type' => 'string', 'description' => 'rev aus lit-content-get (optimistische Sperre).'], + 'title' => ['type' => 'string', 'minLength' => 1], + ], PostWriter::inputProperties()), + 'required' => ['post_id', 'rev'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema(); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + + return $post instanceof WP_Error ? $post : true; + } + + public function execute(array $input): mixed + { + $post = PostGuard::editable($input['post_id'] ?? null, $this->schema); + if ($post instanceof WP_Error) { + return $post; + } + + if (! isset($input['rev']) || $input['rev'] !== Rev::ofPost($post)) { + return Errors::revMismatch(); + } + + if (array_key_exists('content', $input) && PostPresenter::usesDivi($post)) { + return Errors::unsupported( + 'Divi-Inhalte können nicht als Rohtext überschrieben werden; nichts wurde gespeichert.', + 'Divi-Struktur über lit-divi-outline und die lit-divi-*-Tools bearbeiten; Titel, Auszug, ' + . 'Felder und Begriffe hier ohne content ändern.' + ); + } + + $data = $this->writer->postData($input); + if ($data === [] && ! $this->writer->hasExtras($input)) { + return Errors::invalidInput( + 'Nichts zu ändern.', + [], + 'Mindestens eines angeben: ' . implode(', ', self::CHANGE_KEYS) . '.' + ); + } + + $postType = (string) $post->post_type; + $prepared = $this->writer->prepare($postType, $input, false); + if ($prepared instanceof WP_Error) { + return $prepared; + } + + $postId = (int) $post->ID; + $snapshot = $this->writer->snapshot($postId, $postType, $prepared, $input); + $revisionId = RevisionPoint::capture($postId); + + $rowUpdated = false; + try { + if ($data !== []) { + $result = wp_update_post(wp_slash(array_merge($data, ['ID' => $postId])), true); + if ($result instanceof WP_Error) { + return Errors::invalidInput( + 'WordPress hat die Änderung abgelehnt: ' . $result->get_error_message() + ); + } + $rowUpdated = true; + } + + $this->writer->writeExtras($postId, $postType, $prepared, $input); + } catch (Throwable $e) { + $this->rollBack($post, $rowUpdated ? $data : [], $revisionId, $snapshot, $e); + } + do_action('lit_mcp_after_save', $postId, $this->name(), $input); + + $updated = get_post($postId); + $updated = $updated instanceof WP_Post ? $updated : $post; + $changedKeys = array_keys(array_intersect_key($input, array_flip(self::CHANGE_KEYS))); + $summary = sprintf('"%s" geändert: %s', $updated->post_title, implode(', ', $changedKeys)); + $logId = $this->log->record($this->name(), $postType, (string) $postId, $summary, $revisionId); + + return ToolResult::ok(sprintf('%s (Status unverändert: %s).', $summary, $updated->post_status)) + ->changed((string) $postId, $postType, 'updated', (string) $updated->post_title) + ->rev(Rev::ofPost($updated)) + ->revisionId($revisionId) + ->logId($logId) + ->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw')) + ->warnings($prepared['warnings']) + ->toArray(); + } + + /** + * @param array $data Columns that were written to the post row. + * @param array $snapshot Output of PostWriter::snapshot(). + * @throws RuntimeException Always – the original failure plus the rollback outcome. + */ + private function rollBack(WP_Post $post, array $data, ?int $revisionId, array $snapshot, Throwable $cause): never + { + $failures = []; + try { + $this->writer->restore((int) $post->ID, (string) $post->post_type, $snapshot); + } catch (Throwable $e) { + $failures[] = $e->getMessage(); + } + + try { + $this->restoreRow($post, $data, $revisionId); + } catch (Throwable $e) { + $failures[] = $e->getMessage(); + } + + throw new RuntimeException( + sprintf( + 'Update of post %d failed (%s); %s.', + (int) $post->ID, + $cause->getMessage(), + $failures === [] ? 'changes rolled back' : 'rollback incomplete: ' . implode('; ', $failures) + ), + 0, + $cause + ); + } + + /** + * Title, content and excerpt come back from the restore-point revision when that + * revision reflects the pre-update state (revisions enabled: RevisionPoint then + * saved or confirmed it). Everything else – and everything when revisions are off, + * where the newest revision may be stale – gets the value read before the update. + * + * @param array $data + * @throws RuntimeException + */ + private function restoreRow(WP_Post $post, array $data, ?int $revisionId): void + { + if ($data === []) { + return; + } + + $old = []; + foreach (array_keys($data) as $column) { + $old[$column] = $column === 'page_template' + ? ((string) get_page_template_slug($post) ?: PostWriter::DEFAULT_TEMPLATE) + : $post->{$column}; + } + + $fromRevision = array_values(array_intersect(array_keys($old), self::REVISIONED)); + if ($revisionId !== null && $fromRevision !== [] && wp_revisions_enabled($post)) { + $restored = wp_restore_post_revision($revisionId, $fromRevision); + if ($restored && ! is_wp_error($restored)) { + $old = array_diff_key($old, array_flip($fromRevision)); + } + } + + if ($old === []) { + return; + } + + $result = wp_update_post(wp_slash(array_merge($old, ['ID' => (int) $post->ID])), true); + if ($result instanceof WP_Error || ! $result) { + $message = $result instanceof WP_Error ? $result->get_error_message() : 'unknown error'; + throw new RuntimeException('Post row could not be restored: ' . $message); + } + } +} diff --git a/src/Integrations/WordPress/Content/PostPresenter.php b/src/Integrations/WordPress/Content/PostPresenter.php index d238f4b..df50177 100644 --- a/src/Integrations/WordPress/Content/PostPresenter.php +++ b/src/Integrations/WordPress/Content/PostPresenter.php @@ -61,7 +61,7 @@ class PostPresenter 'terms' => JsonMap::of($this->terms($post)), 'fields' => JsonMap::of($this->fields($post)), 'featured_image' => $this->featuredImage($post), - 'rev' => Rev::of((string) $post->post_content), + 'rev' => Rev::ofPost($post), 'hints' => $divi ? [self::DIVI_HINT] : [], ]); } diff --git a/src/Integrations/WordPress/Content/PostWriter.php b/src/Integrations/WordPress/Content/PostWriter.php new file mode 100644 index 0000000..529a304 --- /dev/null +++ b/src/Integrations/WordPress/Content/PostWriter.php @@ -0,0 +1,294 @@ + 'post_title', + 'content' => 'post_content', + 'excerpt' => 'post_excerpt', + 'slug' => 'post_name', + 'parent' => 'post_parent', + 'menu_order' => 'menu_order', + 'template' => 'page_template', + ]; + + /** wp_insert_post() ignores an empty page_template; this value selects the default template. */ + public const DEFAULT_TEMPLATE = 'default'; + + public function __construct( + private ContentSchema $schema, + private FieldValidator $validator, + private TermAssigner $terms + ) { + } + + /** @return array> */ + public static function inputProperties(): array + { + return [ + 'content' => ['type' => 'string', 'description' => 'HTML bzw. Block-Markup (nicht für Divi-Inhalte).'], + 'excerpt' => ['type' => 'string'], + 'slug' => ['type' => 'string'], + 'parent' => ['type' => 'integer', 'minimum' => 0], + 'menu_order' => ['type' => 'integer'], + 'template' => ['type' => 'string', 'description' => 'Seiten-Template (Dateiname), leer = Standard.'], + 'terms' => [ + 'type' => 'object', + 'additionalProperties' => ['type' => 'array', 'items' => ['type' => ['integer', 'string']]], + 'description' => 'Je Taxonomie die vollständige Liste der Begriffe (IDs oder Namen); ' + . 'ersetzt die bisherige Zuordnung dieser Taxonomie.', + ], + 'fields' => [ + 'type' => 'object', + 'description' => 'Feldwerte nach Schlüssel (siehe lit-content-types); null leert ein Feld.', + ], + 'featured_image' => [ + 'type' => ['integer', 'null'], + 'minimum' => 0, + 'description' => 'Anhang-ID des Beitragsbilds; 0 oder null entfernt es.', + ], + ]; + } + + /** + * @param array $input + * @return array|WP_Error Keys fields, terms (taxonomy => list), warnings. + */ + public function prepare(string $postType, array $input, bool $isCreate): array|WP_Error + { + $errors = []; + + $fields = $this->validator->validate( + $this->schema->fields($postType), + is_array($input['fields'] ?? null) ? $input['fields'] : [], + $isCreate + ); + foreach ($fields['errors'] as $key => $message) { + $errors['fields.' . $key] = $message; + } + + $terms = $this->terms->resolve($postType, is_array($input['terms'] ?? null) ? $input['terms'] : []); + foreach ($terms['errors'] as $taxonomy => $message) { + $errors['terms.' . $taxonomy] = $message; + } + + $checks = [ + 'featured_image' => $this->checkFeaturedImage($postType, $input), + 'parent' => $this->checkParent($postType, $input), + 'template' => $this->checkTemplate($postType, $input), + ]; + $errors = array_merge($errors, array_filter($checks, static fn (?string $error): bool => $error !== null)); + + if ($errors !== []) { + return Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $errors); + } + + return ['fields' => $fields['values'], 'terms' => $terms['terms'], 'warnings' => $fields['warnings']]; + } + + /** + * @param array $input + * @return array + */ + public function postData(array $input): array + { + $data = []; + foreach (self::POST_FIELDS as $key => $column) { + if (array_key_exists($key, $input)) { + $data[$column] = $input[$key]; + } + } + + if (($data['page_template'] ?? null) === '') { + $data['page_template'] = self::DEFAULT_TEMPLATE; + } + + return $data; + } + + /** @param array $input */ + public function hasExtras(array $input): bool + { + return ! empty($input['fields']) || ! empty($input['terms']) || array_key_exists('featured_image', $input); + } + + /** + * @param array{fields: array, terms: array>} $prepared + * @param array $input + * @throws RuntimeException + */ + public function writeExtras(int $postId, string $postType, array $prepared, array $input): void + { + $this->terms->assign($postId, $prepared['terms']); + $this->writeFields($postId, $postType, $prepared['fields']); + + if (array_key_exists('featured_image', $input)) { + $this->setFeaturedImage($postId, (int) ($input['featured_image'] ?? 0)); + } + } + + /** + * Current values of everything writeExtras() would change – call before the first write. + * + * @param array{fields: array, terms: array>} $prepared + * @param array $input + * @return array{fields: array, terms: array>, featured_image?: int} + */ + public function snapshot(int $postId, string $postType, array $prepared, array $input): array + { + $definitions = $this->schema->fields($postType); + $fields = []; + foreach (array_keys($prepared['fields']) as $key) { + $definition = $definitions[$key]; + $old = $this->schema->sourceOf($definition)->read($postId, $definition); + $fields[$key] = in_array($old, ['', [], null, false], true) ? null : $old; + } + + $terms = []; + foreach (array_keys($prepared['terms']) as $taxonomy) { + $ids = wp_get_object_terms($postId, $taxonomy, ['fields' => 'ids']); + if (is_wp_error($ids)) { + throw new RuntimeException('Terms could not be read: ' . $ids->get_error_message()); + } + $terms[$taxonomy] = array_map('intval', (array) $ids); + } + + $snapshot = ['fields' => $fields, 'terms' => $terms]; + if (array_key_exists('featured_image', $input)) { + $snapshot['featured_image'] = (int) get_post_thumbnail_id($postId); + } + + return $snapshot; + } + + /** + * Writes a snapshot back. Tries every part even if one fails. + * + * @param array{fields: array, terms: array>, featured_image?: int} $snapshot + * @throws RuntimeException Listing every part that could not be restored. + */ + public function restore(int $postId, string $postType, array $snapshot): void + { + $failures = []; + $steps = [ + fn () => $this->terms->assign($postId, $snapshot['terms']), + fn () => $this->writeFields($postId, $postType, $snapshot['fields']), + ]; + if (array_key_exists('featured_image', $snapshot)) { + $steps[] = fn () => $this->setFeaturedImage($postId, (int) $snapshot['featured_image']); + } + + foreach ($steps as $step) { + try { + $step(); + } catch (Throwable $e) { + $failures[] = $e->getMessage(); + } + } + + if ($failures !== []) { + throw new RuntimeException('Restore incomplete: ' . implode('; ', $failures)); + } + } + + /** + * @param array $values + * @throws RuntimeException + */ + private function writeFields(int $postId, string $postType, array $values): void + { + $definitions = $this->schema->fields($postType); + $bySource = []; + foreach ($values as $key => $value) { + $bySource[$definitions[$key]->source][$key] = $value; + } + + foreach ($bySource as $sourceValues) { + $first = $definitions[(string) array_key_first($sourceValues)]; + $this->schema->sourceOf($first)->write($postId, $postType, $sourceValues, $definitions); + } + } + + /** @throws RuntimeException */ + private function setFeaturedImage(int $postId, int $imageId): void + { + $result = $imageId > 0 ? set_post_thumbnail($postId, $imageId) : delete_post_thumbnail($postId); + + // Both return false as well when nothing had to change. + if ($result === false && (int) get_post_thumbnail_id($postId) !== max($imageId, 0)) { + throw new RuntimeException(sprintf('Featured image of post %d could not be set.', $postId)); + } + } + + /** @param array $input */ + private function checkFeaturedImage(string $postType, array $input): ?string + { + $imageId = (int) ($input['featured_image'] ?? 0); + if ($imageId === 0) { + return null; + } + + if (! post_type_supports($postType, 'thumbnail')) { + return 'Inhaltstyp unterstützt kein Beitragsbild.'; + } + + return wp_attachment_is_image($imageId) ? null : 'Kein Bild aus der Mediathek.'; + } + + /** @param array $input */ + private function checkParent(string $postType, array $input): ?string + { + $parentId = (int) ($input['parent'] ?? 0); + if ($parentId === 0) { + return null; + } + + $parent = get_post($parentId); + + return $parent instanceof WP_Post && $parent->post_type === $postType + ? null + : 'Übergeordneter Eintrag muss existieren und denselben Inhaltstyp haben.'; + } + + /** + * wp_insert_post() rejects an unknown template only after the row was written, + * so it is checked here, before anything is saved. + * + * @param array $input + */ + private function checkTemplate(string $postType, array $input): ?string + { + $template = $input['template'] ?? ''; + if (! is_string($template) || $template === '' || $template === self::DEFAULT_TEMPLATE) { + return null; + } + + $templates = (array) wp_get_theme()->get_page_templates(null, $postType); + if (isset($templates[$template])) { + return null; + } + + return $templates === [] + ? 'Für diesen Inhaltstyp gibt es keine Seiten-Templates; leer lassen für den Standard.' + : sprintf('Unbekanntes Template. Erlaubt: %s (leer = Standard).', implode(', ', array_keys($templates))); + } +} diff --git a/src/Integrations/WordPress/Schema/MetaFieldSource.php b/src/Integrations/WordPress/Schema/MetaFieldSource.php index 9470f6f..328c5eb 100644 --- a/src/Integrations/WordPress/Schema/MetaFieldSource.php +++ b/src/Integrations/WordPress/Schema/MetaFieldSource.php @@ -4,8 +4,11 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\WordPress\Schema; +use RuntimeException; + /** - * Shared read/write via the post meta API. + * Shared read/write via the post meta API. write() throws when WordPress did not + * store a value, so callers can roll back (all or nothing, spec §8.2). */ abstract class MetaFieldSource implements FieldSource { @@ -19,20 +22,50 @@ abstract class MetaFieldSource implements FieldSource foreach ($values as $key => $value) { $key = (string) $key; if ($value === null) { - delete_post_meta($postId, $key); + $this->delete($postId, $key); continue; } $field = $definitions[$key] ?? null; if ($field !== null && $field->multiple && is_array($value) && array_is_list($value)) { - delete_post_meta($postId, $key); + $this->delete($postId, $key); foreach ($value as $item) { - add_post_meta($postId, $key, wp_slash($item)); + if (add_post_meta($postId, $key, wp_slash($item)) === false) { + throw new RuntimeException(sprintf('Meta "%s" of post %d could not be added.', $key, $postId)); + } } continue; } - update_post_meta($postId, $key, wp_slash($value)); + // update_post_meta() also returns false when the value is unchanged – only a + // stored value that still differs is a failure. + if (update_post_meta($postId, $key, wp_slash($value)) === false && ! $this->holds($postId, $key, $value)) { + throw new RuntimeException(sprintf('Meta "%s" of post %d could not be saved.', $key, $postId)); + } } } + + private function delete(int $postId, string $key): void + { + // delete_post_meta() returns false as well when there was nothing to delete. + if (delete_post_meta($postId, $key) === false && metadata_exists('post', $postId, $key)) { + throw new RuntimeException(sprintf('Meta "%s" of post %d could not be deleted.', $key, $postId)); + } + } + + private function holds(int $postId, string $key, mixed $value): bool + { + $stored = get_post_meta($postId, $key, false); + if (! is_array($stored) || count($stored) !== 1) { + return false; + } + + $current = $stored[0]; + if (is_scalar($current) && is_scalar($value)) { + // Meta is stored as text: 3 and "3", true and "1" are the same stored value. + return (string) $current === (string) $value; + } + + return $current == $value; // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual + } } diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php index 882fe4b..3450a79 100644 --- a/src/Integrations/WordPress/WordPressIntegration.php +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -6,11 +6,16 @@ namespace LIT\MCP\Integrations\WordPress; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Integrations\Integration; +use LIT\MCP\Integrations\WordPress\Content\ContentCreateAbility; use LIT\MCP\Integrations\WordPress\Content\ContentGetAbility; use LIT\MCP\Integrations\WordPress\Content\ContentQueryAbility; use LIT\MCP\Integrations\WordPress\Content\ContentTypesAbility; +use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility; use LIT\MCP\Integrations\WordPress\Content\PostPresenter; +use LIT\MCP\Integrations\WordPress\Content\PostWriter; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; +use LIT\MCP\Integrations\WordPress\Schema\FieldValidator; +use LIT\MCP\Integrations\WordPress\Schema\TermAssigner; /** * WordPress basics (spec §6): content, status, copies, revisions, menus, settings. @@ -35,11 +40,14 @@ final class WordPressIntegration implements Integration public function abilities(): array { $presenter = new PostPresenter($this->schema); + $writer = new PostWriter($this->schema, new FieldValidator(), new TermAssigner()); return [ new ContentTypesAbility($this->schema), new ContentQueryAbility($this->schema, $presenter), new ContentGetAbility($this->schema, $presenter), + new ContentCreateAbility($this->schema, $writer, $this->log), + new ContentUpdateAbility($this->schema, $writer, $this->log), ]; } } diff --git a/tests/Stubs/WpPost.php b/tests/Stubs/WpPost.php index 47628d6..2c53bc0 100644 --- a/tests/Stubs/WpPost.php +++ b/tests/Stubs/WpPost.php @@ -21,6 +21,7 @@ if (! class_exists('WP_Post')) { public $post_date = '2026-09-25 10:00:00'; public $post_date_gmt = '2026-09-25 08:00:00'; public $post_modified = '2026-09-25 10:00:00'; + public $post_modified_gmt = '2026-09-25 08:00:00'; public $post_mime_type = ''; public $comment_status = 'closed'; public $ping_status = 'closed'; diff --git a/tests/Unit/Abilities/RevAndJsonMapTest.php b/tests/Unit/Abilities/RevAndJsonMapTest.php index 2141af7..096fe56 100644 --- a/tests/Unit/Abilities/RevAndJsonMapTest.php +++ b/tests/Unit/Abilities/RevAndJsonMapTest.php @@ -8,6 +8,7 @@ use LIT\MCP\Abilities\JsonMap; use LIT\MCP\Abilities\Rev; use LIT\MCP\Tests\Unit\TestCase; use stdClass; +use WP_Post; final class RevAndJsonMapTest extends TestCase { @@ -18,6 +19,26 @@ final class RevAndJsonMapTest extends TestCase self::assertNotSame(Rev::of('abc'), Rev::of('abd')); } + public function testPostRevCoversContentTitleExcerptStatusAndModification(): void + { + $base = [ + 'post_content' => '

A

', + 'post_title' => 'T', + 'post_excerpt' => 'E', + 'post_status' => 'draft', + 'post_modified_gmt' => '2026-09-25 08:00:00', + ]; + $rev = Rev::ofPost(new WP_Post($base)); + + self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', $rev); + self::assertSame($rev, Rev::ofPost(new WP_Post($base))); + self::assertNotSame(Rev::of('

A

'), $rev); + foreach (['post_content' => '

B

', 'post_title' => 'U', 'post_excerpt' => 'F', + 'post_status' => 'publish', 'post_modified_gmt' => '2026-09-25 08:00:01'] as $key => $value) { + self::assertNotSame($rev, Rev::ofPost(new WP_Post(array_merge($base, [$key => $value]))), $key); + } + } + public function testEmptyMapBecomesJsonObject(): void { self::assertInstanceOf(stdClass::class, JsonMap::of([])); diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php new file mode 100644 index 0000000..42051c8 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateAbilityTest.php @@ -0,0 +1,114 @@ +justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]); + Functions\when('current_user_can')->justReturn(true); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + + return $schema; + } + + public function testInvalidInputWritesNothing(): void + { + $writer = Mockery::mock(PostWriter::class); + $writer->allows('prepare')->andReturn(Errors::invalidInput('ungültig', ['fields.role' => 'Pflichtfeld fehlt.'])); + Functions\expect('wp_insert_post')->never(); + + $result = (new ContentCreateAbility($this->schema(), $writer, Mockery::mock(ChangeLog::class))) + ->execute(['post_type' => 'lit_person', 'title' => 'Anna']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testCreatesADraftWritesExtrasLogsAndReturnsAToolResult(): void + { + $schema = $this->schema(); + $prepared = ['fields' => ['role' => 'Leitung'], 'terms' => [], 'warnings' => ['w']]; + $input = ['post_type' => 'lit_person', 'title' => 'Anna', 'fields' => ['role' => 'Leitung']]; + $writer = Mockery::mock(PostWriter::class); + $writer->allows('prepare')->with('lit_person', $input, true)->andReturn($prepared); + $writer->allows('postData')->andReturn(['post_title' => 'Anna']); + $writer->expects('writeExtras')->with(42, 'lit_person', $prepared, $input); + Functions\when('get_current_user_id')->justReturn(3); + Functions\expect('wp_insert_post')->once()->with([ + 'post_title' => 'Anna', + 'post_type' => 'lit_person', + 'post_status' => 'draft', + 'post_author' => 3, + ], true)->andReturn(42); + Actions\expectDone('lit_mcp_after_save')->once()->with(42, 'lit/content-create', $input); + $post = new WP_Post(['ID' => 42, 'post_title' => 'Anna', 'post_type' => 'lit_person']); + Functions\when('get_post')->justReturn($post); + Functions\when('get_preview_post_link')->justReturn('https://kunde.de/?p=42&preview=true'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=42&action=edit'); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/content-create', 'lit_person', '42', 'Entwurf "Anna" angelegt')->andReturn(7); + + $result = (new ContentCreateAbility($schema, $writer, $log))->execute($input); + + self::assertTrue($result['ok']); + self::assertSame([['id' => '42', 'type' => 'lit_person', 'action' => 'created', 'label' => 'Anna']], $result['changed']); + self::assertSame(7, $result['log_id']); + self::assertSame(['w'], $result['warnings']); + self::assertStringContainsString('Entwurf', $result['summary']); + self::assertSame(Rev::ofPost($post), $result['rev']); + } + + public function testFailingExtrasDeleteTheNewDraftAndNothingIsLogged(): void + { + $schema = $this->schema(); + $input = ['post_type' => 'lit_person', 'title' => 'Anna', 'fields' => ['role' => 'Leitung']]; + $writer = Mockery::mock(PostWriter::class); + $writer->allows('prepare')->andReturn(['fields' => ['role' => 'Leitung'], 'terms' => [], 'warnings' => []]); + $writer->allows('postData')->andReturn(['post_title' => 'Anna']); + $writer->expects('writeExtras')->andThrow(new RuntimeException('Pods could not save the fields.')); + Functions\when('get_current_user_id')->justReturn(3); + Functions\when('wp_insert_post')->justReturn(42); + Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42])); + Actions\expectDone('lit_mcp_after_save')->never(); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->never(); + + try { + (new ContentCreateAbility($schema, $writer, $log))->execute($input); + self::fail('RuntimeException expected'); + } catch (RuntimeException $e) { + self::assertStringContainsString('Pods could not save the fields.', $e->getMessage()); + self::assertStringContainsString('deleted', $e->getMessage()); + } + } + + public function testStatusIsNotAnInputAndPermissionNeedsCreateRights(): void + { + $schema = $this->schema(); + $ability = new ContentCreateAbility($schema, Mockery::mock(PostWriter::class), Mockery::mock(ChangeLog::class)); + + self::assertArrayNotHasKey('status', $ability->inputSchema()['properties']); + self::assertFalse($ability->inputSchema()['additionalProperties']); + self::assertTrue($ability->permission(['post_type' => 'page'])); + + Functions\when('current_user_can')->justReturn(false); + self::assertSame('lit_forbidden', $ability->permission(['post_type' => 'page'])->get_error_code()); + } +} diff --git a/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php new file mode 100644 index 0000000..0d716c7 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Content/ContentUpdateAbilityTest.php @@ -0,0 +1,217 @@ +post = new WP_Post([ + 'ID' => 5, + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Team', + 'post_name' => 'team', + 'post_content' => '

Alt

', + ]); + Functions\when('get_post')->justReturn($this->post); + Functions\when('current_user_can')->justReturn(true); + } + + private function schema(): ContentSchema + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + + return $schema; + } + + private function ability(PostWriter $writer, ?ChangeLog $log = null): ContentUpdateAbility + { + return new ContentUpdateAbility($this->schema(), $writer, $log ?? Mockery::mock(ChangeLog::class)); + } + + public function testRevIsRequired(): void + { + $writer = Mockery::mock(PostWriter::class); + Functions\expect('wp_update_post')->never(); + + self::assertContains('rev', $this->ability($writer)->inputSchema()['required']); + self::assertSame( + 'lit_rev_mismatch', + $this->ability($writer)->execute(['post_id' => 5, 'title' => 'Neu'])->get_error_code() + ); + } + + public function testStaleRevIsRejectedWithoutWriting(): void + { + Functions\expect('wp_update_post')->never(); + + $result = $this->ability(Mockery::mock(PostWriter::class)) + ->execute(['post_id' => 5, 'rev' => 'stale', 'title' => 'Neu']); + + self::assertSame('lit_rev_mismatch', $result->get_error_code()); + self::assertStringContainsString('lit-content-get', $result->get_error_data()['hint']); + } + + public function testAContentOnlyRevIsStaleBecauseTitleAndStatusCountToo(): void + { + $result = $this->ability(Mockery::mock(PostWriter::class)) + ->execute(['post_id' => 5, 'rev' => Rev::of('

Alt

'), 'title' => 'Neu']); + + self::assertSame('lit_rev_mismatch', $result->get_error_code()); + } + + public function testRawContentOnDiviPagesIsUnsupported(): void + { + Functions\when('get_post_meta')->justReturn('on'); + $writer = Mockery::mock(PostWriter::class); + $writer->expects('prepare')->never(); + + $result = $this->ability($writer) + ->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'content' => '

Neu

']); + + self::assertSame('lit_unsupported', $result->get_error_code()); + self::assertStringContainsString('lit-divi-outline', $result->get_error_message()); + } + + public function testNothingToChangeIsAValidationError(): void + { + $writer = Mockery::mock(PostWriter::class); + $writer->allows('postData')->andReturn([]); + $writer->allows('hasExtras')->andReturn(false); + + $result = $this->ability($writer)->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post)]); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testUpdateKeepsTheStatusAndCapturesARestorePointFirst(): void + { + $order = []; + $prepared = ['fields' => [], 'terms' => [], 'warnings' => []]; + $input = ['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'title' => 'Unser Team']; + $writer = Mockery::mock(PostWriter::class); + $writer->allows('postData')->andReturn(['post_title' => 'Unser Team']); + $writer->allows('hasExtras')->andReturn(false); + $writer->allows('prepare')->with('page', $input, false)->andReturn($prepared); + $writer->allows('snapshot')->andReturn(['fields' => [], 'terms' => []]); + $writer->expects('writeExtras')->with(5, 'page', $prepared, $input); + Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) { + $order[] = 'revision'; + return 811; + }); + Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]); + Functions\expect('wp_update_post')->once()->with(['post_title' => 'Unser Team', 'ID' => 5], true) + ->andReturnUsing(static function () use (&$order): int { + $order[] = 'update'; + return 5; + }); + Actions\expectDone('lit_mcp_after_save')->once()->with(5, 'lit/content-update', $input); + Functions\when('get_preview_post_link')->justReturn('https://kunde.de/team/'); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit'); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/content-update', 'page', '5', Mockery::type('string'), 811)->andReturn(4711); + + $result = $this->ability($writer, $log)->execute($input); + + self::assertSame(['revision', 'update'], $order); + self::assertSame(811, $result['revision_id']); + self::assertSame(4711, $result['log_id']); + self::assertSame('updated', $result['changed'][0]['action']); + self::assertSame(Rev::ofPost($this->post), $result['rev']); + self::assertStringContainsString('Status unverändert: publish', $result['summary']); + } + + /** + * @param array $data + * @return array{PostWriter, array, array} + */ + private function failingWriter(array $data): array + { + $input = ['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'title' => 'Unser Team', 'fields' => ['a' => 1]]; + $snapshot = ['fields' => ['a' => 0], 'terms' => []]; + $writer = Mockery::mock(PostWriter::class); + $writer->allows('postData')->andReturn($data); + $writer->allows('prepare')->andReturn(['fields' => ['a' => 1], 'terms' => [], 'warnings' => []]); + $writer->expects('snapshot')->with(5, 'page', Mockery::type('array'), $input)->andReturn($snapshot); + $writer->expects('writeExtras')->andThrow(new RuntimeException('meta kaputt')); + $writer->expects('restore')->with(5, 'page', $snapshot); + Functions\when('wp_save_post_revision')->justReturn(811); + Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]); + Functions\when('get_page_template_slug')->justReturn(''); + Actions\expectDone('lit_mcp_after_save')->never(); + + return [$writer, $input, $snapshot]; + } + + /** @param array $input */ + private function executeExpectingRollback(PostWriter $writer, array $input): void + { + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->never(); + + try { + $this->ability($writer, $log)->execute($input); + self::fail('RuntimeException expected'); + } catch (RuntimeException $e) { + self::assertStringContainsString('meta kaputt', $e->getMessage()); + self::assertStringContainsString('rolled back', $e->getMessage()); + } + } + + public function testFailingExtrasRestoreTheRevisionAndTheOldValues(): void + { + [$writer, $input] = $this->failingWriter(['post_title' => 'Unser Team', 'post_name' => 'unser-team']); + $updates = []; + Functions\when('wp_update_post')->alias(static function (array $data) use (&$updates): int { + $updates[] = $data; + return 5; + }); + Functions\when('wp_revisions_enabled')->justReturn(true); + Functions\expect('wp_restore_post_revision')->once()->with(811, ['post_title'])->andReturn(5); + + $this->executeExpectingRollback($writer, $input); + + self::assertSame([ + ['post_title' => 'Unser Team', 'post_name' => 'unser-team', 'ID' => 5], + ['post_name' => 'team', 'ID' => 5], + ], $updates); + } + + public function testWithoutRevisionsTheOldColumnValuesAreWrittenBack(): void + { + [$writer, $input] = $this->failingWriter(['post_title' => 'Unser Team', 'page_template' => 'tpl-wide.php']); + $updates = []; + Functions\when('wp_update_post')->alias(static function (array $data) use (&$updates): int { + $updates[] = $data; + return 5; + }); + Functions\when('wp_revisions_enabled')->justReturn(false); + Functions\expect('wp_restore_post_revision')->never(); + + $this->executeExpectingRollback($writer, $input); + + self::assertSame([ + ['post_title' => 'Unser Team', 'page_template' => 'tpl-wide.php', 'ID' => 5], + ['post_title' => 'Team', 'page_template' => 'default', 'ID' => 5], + ], $updates); + } +} diff --git a/tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php b/tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php index a3055e1..47d4ca5 100644 --- a/tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php +++ b/tests/Unit/Integrations/WordPress/Content/PostPresenterTest.php @@ -68,7 +68,7 @@ final class PostPresenterTest extends TestCase self::assertSame(['role' => 'Leitung'], $full['fields']); self::assertSame(['id' => 33, 'url' => 'https://kunde.de/wp-content/uploads/foto.jpg', 'alt' => 'Porträt'], $full['featured_image']); self::assertSame(['id' => 2, 'name' => 'Jan'], $full['author']); - self::assertSame(Rev::of('

Hallo

'), $full['rev']); + self::assertSame(Rev::ofPost($post), $full['rev']); self::assertSame([], $full['hints']); } @@ -90,7 +90,7 @@ final class PostPresenterTest extends TestCase self::assertStringContainsString('lit-divi-outline', $full['hints'][0]); self::assertInstanceOf(stdClass::class, $full['terms']); self::assertInstanceOf(stdClass::class, $full['fields']); - self::assertSame(Rev::of(''), $full['rev']); + self::assertSame(Rev::ofPost($post), $full['rev']); } public function testPasswordAndProtectedReadonlyMetaFieldsAreOmitted(): void diff --git a/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php b/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php new file mode 100644 index 0000000..2f36b00 --- /dev/null +++ b/tests/Unit/Integrations/WordPress/Content/PostWriterTest.php @@ -0,0 +1,191 @@ +allows('get_page_templates')->with(null, 'lit_person')->andReturn(['tpl-wide.php' => 'Breit']); + Functions\when('wp_get_theme')->justReturn($theme); + } + + public function testPrepareCollectsAllErrorsWithPaths(): void + { + $this->stubTheme(); + Functions\when('get_object_taxonomies')->justReturn([]); + Functions\when('post_type_supports')->justReturn(false); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 3, 'post_type' => 'post'])); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('fields')->andReturn([ + 'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable', true), + ]); + $writer = new PostWriter($schema, new FieldValidator(), new TermAssigner()); + + $result = $writer->prepare('lit_person', [ + 'terms' => ['genre' => [1]], + 'featured_image' => 9, + 'parent' => 3, + 'template' => 'fehlt.php', + ], true); + + $fields = $result->get_error_data()['fields']; + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertSame('Pflichtfeld fehlt.', $fields['fields.role']); + self::assertSame('Taxonomie gehört nicht zu diesem Inhaltstyp.', $fields['terms.genre']); + self::assertSame('Inhaltstyp unterstützt kein Beitragsbild.', $fields['featured_image']); + self::assertStringContainsString('denselben Inhaltstyp', $fields['parent']); + self::assertStringContainsString('tpl-wide.php', $fields['template']); + } + + public function testKnownDefaultAndEmptyTemplatesAreValid(): void + { + $this->stubTheme(); + Functions\when('get_object_taxonomies')->justReturn([]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('fields')->andReturn([]); + $writer = new PostWriter($schema, new FieldValidator(), new TermAssigner()); + + foreach (['tpl-wide.php', 'default', ''] as $template) { + self::assertIsArray($writer->prepare('lit_person', ['template' => $template], false), $template); + } + } + + public function testPrepareReturnsValuesTermsAndWarnings(): void + { + Functions\when('get_object_taxonomies')->justReturn([]); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('fields')->andReturn(['city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown')]); + + $result = (new PostWriter($schema, new FieldValidator(), new TermAssigner())) + ->prepare('page', ['fields' => ['city' => 'Trier']], false); + + self::assertSame(['city' => 'Trier'], $result['fields']); + self::assertSame([], $result['terms']); + self::assertCount(1, $result['warnings']); + } + + public function testPostDataMapsOnlyGivenKeys(): void + { + $writer = new PostWriter(Mockery::mock(ContentSchema::class), new FieldValidator(), new TermAssigner()); + + // wp_insert_post() ignores an empty page_template, so "empty = default template" is sent as 'default'. + self::assertSame( + ['post_title' => 'Team', 'post_name' => 'team', 'page_template' => 'default'], + $writer->postData(['title' => 'Team', 'slug' => 'team', 'template' => '', 'fields' => []]) + ); + self::assertTrue($writer->hasExtras(['featured_image' => null])); + self::assertFalse($writer->hasExtras(['title' => 'x', 'fields' => []])); + } + + public function testWriteExtrasGroupsFieldsBySourceAndHandlesTheFeaturedImage(): void + { + $role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'); + $city = new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'); + $definitions = ['city' => $city, 'role' => $role]; + $pods = Mockery::mock(FieldSource::class); + $pods->expects('write')->with(5, 'lit_person', ['role' => 'Leitung'], $definitions); + $sample = Mockery::mock(FieldSource::class); + $sample->expects('write')->with(5, 'lit_person', ['city' => 'Trier'], $definitions); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('fields')->andReturn($definitions); + $schema->allows('sourceOf')->andReturnUsing(static fn (FieldDefinition $f) => $f->source === 'pods' ? $pods : $sample); + Functions\expect('delete_post_thumbnail')->once()->with(5); + + (new PostWriter($schema, new FieldValidator(), new TermAssigner()))->writeExtras( + 5, + 'lit_person', + ['fields' => ['role' => 'Leitung', 'city' => 'Trier'], 'terms' => [], 'warnings' => []], + ['featured_image' => 0] + ); + } + + public function testAFeaturedImageThatDidNotStickThrows(): void + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('fields')->andReturn([]); + Functions\when('set_post_thumbnail')->justReturn(false); + Functions\when('get_post_thumbnail_id')->justReturn(0); + + $this->expectException(RuntimeException::class); + (new PostWriter($schema, new FieldValidator(), new TermAssigner()))->writeExtras( + 5, + 'lit_person', + ['fields' => [], 'terms' => [], 'warnings' => []], + ['featured_image' => 9] + ); + } + + public function testSnapshotKeepsTheOldValuesOfEverythingThatWillBeWrittenAndRestoreWritesThemBack(): void + { + $role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'); + $city = new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'); + $definitions = ['city' => $city, 'role' => $role]; + $pods = Mockery::mock(FieldSource::class); + $pods->allows('read')->with(5, $role)->andReturn('Alt'); + $pods->expects('write')->with(5, 'lit_person', ['role' => 'Alt'], $definitions); + $sample = Mockery::mock(FieldSource::class); + $sample->allows('read')->with(5, $city)->andReturn(''); + $sample->expects('write')->with(5, 'lit_person', ['city' => null], $definitions); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('fields')->andReturn($definitions); + $schema->allows('sourceOf')->andReturnUsing(static fn (FieldDefinition $f) => $f->source === 'pods' ? $pods : $sample); + Functions\expect('wp_get_object_terms')->once()->with(5, 'category', ['fields' => 'ids'])->andReturn(['2', 3]); + Functions\when('get_post_thumbnail_id')->justReturn(7); + $writer = new PostWriter($schema, new FieldValidator(), new TermAssigner()); + + $snapshot = $writer->snapshot( + 5, + 'lit_person', + ['fields' => ['role' => 'Neu', 'city' => 'Trier'], 'terms' => ['category' => [4]], 'warnings' => []], + ['featured_image' => 9] + ); + + self::assertSame( + ['fields' => ['role' => 'Alt', 'city' => null], 'terms' => ['category' => [2, 3]], 'featured_image' => 7], + $snapshot + ); + + Functions\expect('wp_set_object_terms')->once()->with(5, [2, 3], 'category', false)->andReturn([2, 3]); + Functions\expect('set_post_thumbnail')->once()->with(5, 7)->andReturn(true); + $writer->restore(5, 'lit_person', $snapshot); + } + + public function testRestoreTriesEveryStepAndReportsFailures(): void + { + $role = new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'); + $pods = Mockery::mock(FieldSource::class); + $pods->expects('write')->andThrow(new RuntimeException('pods kaputt')); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('fields')->andReturn(['role' => $role]); + $schema->allows('sourceOf')->andReturn($pods); + Functions\expect('wp_set_object_terms')->once()->andReturn([2]); + Functions\expect('delete_post_thumbnail')->once()->andReturn(true); + + try { + (new PostWriter($schema, new FieldValidator(), new TermAssigner()))->restore( + 5, + 'lit_person', + ['fields' => ['role' => 'Alt'], 'terms' => ['category' => [2]], 'featured_image' => 0] + ); + self::fail('RuntimeException expected'); + } catch (RuntimeException $e) { + self::assertStringContainsString('pods kaputt', $e->getMessage()); + } + } +} diff --git a/tests/Unit/Integrations/WordPress/Schema/RegisteredMetaSourceTest.php b/tests/Unit/Integrations/WordPress/Schema/RegisteredMetaSourceTest.php index f4972a4..306fe06 100644 --- a/tests/Unit/Integrations/WordPress/Schema/RegisteredMetaSourceTest.php +++ b/tests/Unit/Integrations/WordPress/Schema/RegisteredMetaSourceTest.php @@ -8,6 +8,7 @@ use Brain\Monkey\Functions; use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition; use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource; use LIT\MCP\Tests\Unit\TestCase; +use RuntimeException; final class RegisteredMetaSourceTest extends TestCase { @@ -89,4 +90,54 @@ final class RegisteredMetaSourceTest extends TestCase ['footnotes' => $single, 'tags_list' => $multi, 'old' => $gone] ); } + + public function testUnchangedValueIsNotAFailure(): void + { + $field = new FieldDefinition('count', 'count', 'integer', 'meta', 'writable'); + Functions\expect('update_post_meta')->once()->with(5, 'count', 3)->andReturn(false); + Functions\expect('get_post_meta')->once()->with(5, 'count', false)->andReturn(['3']); + + (new RegisteredMetaSource())->write(5, 'event', ['count' => 3], ['count' => $field]); + self::addToAssertionCount(1); + } + + public function testFailedUpdateThrows(): void + { + $field = new FieldDefinition('count', 'count', 'integer', 'meta', 'writable'); + Functions\when('update_post_meta')->justReturn(false); + Functions\when('get_post_meta')->justReturn(['2']); + + $this->expectException(RuntimeException::class); + (new RegisteredMetaSource())->write(5, 'event', ['count' => 3], ['count' => $field]); + } + + public function testFailedDeleteOfAnExistingKeyThrows(): void + { + $field = new FieldDefinition('old', 'old', 'string', 'meta', 'writable'); + Functions\when('delete_post_meta')->justReturn(false); + Functions\expect('metadata_exists')->once()->with('post', 5, 'old')->andReturn(true); + + $this->expectException(RuntimeException::class); + (new RegisteredMetaSource())->write(5, 'event', ['old' => null], ['old' => $field]); + } + + public function testDeletingAMissingKeyIsNotAFailure(): void + { + $field = new FieldDefinition('old', 'old', 'string', 'meta', 'writable'); + Functions\when('delete_post_meta')->justReturn(false); + Functions\expect('metadata_exists')->once()->with('post', 5, 'old')->andReturn(false); + + (new RegisteredMetaSource())->write(5, 'event', ['old' => null], ['old' => $field]); + self::addToAssertionCount(1); + } + + public function testFailedAddOfAListItemThrows(): void + { + $multi = new FieldDefinition('tags_list', 'tags_list', 'array', 'meta', 'writable', false, true); + Functions\when('delete_post_meta')->justReturn(true); + Functions\when('add_post_meta')->justReturn(false); + + $this->expectException(RuntimeException::class); + (new RegisteredMetaSource())->write(5, 'event', ['tags_list' => ['a']], ['tags_list' => $multi]); + } } diff --git a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php index ad76ae1..e9a6529 100644 --- a/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php +++ b/tests/Unit/Integrations/WordPress/WordPressIntegrationTest.php @@ -18,6 +18,8 @@ final class WordPressIntegrationTest extends TestCase 'lit/content-types', 'lit/content-query', 'lit/content-get', + 'lit/content-create', + 'lit/content-update', ]; public function testProvidesTheWordPressAbilitiesInOrder(): void