From 2827fd4bd82d08d39783f6dce9dd3cbf027b3c80 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 16:23:01 +0200 Subject: [PATCH] fix: tb-create-template rolls back posts Divi inserted before failing (ruling Q19) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/ThemeBuilder/DiviThemeBuilder.php | 66 ++++++++++- .../ThemeBuilder/InsertedBeforeFailure.php | 24 ++++ .../ThemeBuilder/TbCreateTemplateAbility.php | 3 +- .../Divi/ThemeBuilder/TemplateBuild.php | 67 +++++++++-- .../ThemeBuilder/DiviThemeBuilderTest.php | 93 +++++++++++++++ .../TbCreateTemplateAbilityTest.php | 58 ++++++++++ .../Divi/ThemeBuilder/TemplateBuildTest.php | 109 +++++++++++++++++- 7 files changed, 406 insertions(+), 14 deletions(-) create mode 100644 src/Integrations/Divi/ThemeBuilder/InsertedBeforeFailure.php diff --git a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php index f51d64b..9a20cee 100644 --- a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php +++ b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\ThemeBuilder; use RuntimeException; +use Throwable; use WP_Error; defined('ABSPATH') || exit; @@ -60,6 +61,9 @@ class DiviThemeBuilder 'wp_die_xml_handler', 'wp_die_xmlrpc_handler', ]; + /** Actions of wp_insert_post() that name a post's ID after its row exists, earliest first (Q19). */ + private const INSERT_ACTIONS = ['clean_post_cache', 'save_post', 'wp_insert_post']; + /** "header", "body" or "footer" for a layout post type, else null. */ public static function areaOf(string $postType): ?string { @@ -168,7 +172,8 @@ class DiviThemeBuilder $type = self::LAYOUT_TYPES[$area] ?? throw new RuntimeException('unknown Theme Builder area ' . $area); $this->assertCanWrite('et_theme_builder_insert_layout'); $id = $this->guarded( - static fn (): mixed => et_theme_builder_insert_layout(['post_type' => $type, 'post_title' => $title]) + static fn (): mixed => et_theme_builder_insert_layout(['post_type' => $type, 'post_title' => $title]), + [$type] ); if (! is_int($id) || $id <= 0) { $reason = $id instanceof WP_Error ? $id->get_error_message() : 'no id'; @@ -198,7 +203,10 @@ class DiviThemeBuilder 'use_on' => $template['use_on'], 'exclude_from' => $template['exclude_from'], ]; - $id = $this->guarded(static fn (): mixed => et_theme_builder_store_template($themeBuilderId, $payload, false)); + $id = $this->guarded( + static fn (): mixed => et_theme_builder_store_template($themeBuilderId, $payload, false), + [self::TEMPLATE] + ); if (! is_int($id) || $id <= 0) { throw new RuntimeException('et_theme_builder_store_template stored no template'); } @@ -338,9 +346,50 @@ class DiviThemeBuilder * the exception leaves that WP_Hook mid-iteration; its nesting level and $wp_current_filter are not * restored (WordPress offers no API for that), so later hooks of this request may see a stale filter * stack. The request ends with the tool's error anyway; the check before Divi is the primary defense. + * + * New posts of $types inserted meanwhile are watched (ruling Q19): wp_insert_post_data marks a pending insert + * (not an update) of such a type, the first clean_post_cache/save_post/wp_insert_post for a post of that type + * after it names its ID. When $call then throws, InsertedBeforeFailure carries those IDs so the caller removes + * them too. Limit: a throw between the row insert and WordPress' first clean_post_cache() (default terms or + * meta_input of a taxonomy/field registered for these types – none by default) leaves the ID unknown. + * + * @param list $types + * @throws RuntimeException InsertedBeforeFailure when posts were inserted before the failure. */ - private function guarded(callable $call): mixed + private function guarded(callable $call, array $types = []): mixed { + $pending = []; + $inserted = []; + $data = static function ( + mixed $data, + mixed $postarr = [], + mixed $unsanitized = [], + bool $update = false + ) use ( + $types, + &$pending + ): mixed { + $type = is_array($data) ? (string) ($data['post_type'] ?? '') : ''; + if (! $update && in_array($type, $types, true)) { + $pending[$type] = ($pending[$type] ?? 0) + 1; + } + + return $data; + }; + $named = static function (mixed $postId, mixed $post = null) use (&$pending, &$inserted): void { + $id = (int) $postId; + $type = $post instanceof \WP_Post ? (string) $post->post_type : (string) get_post_type($id); + if ($id > 0 && ($pending[$type] ?? 0) > 0 && ! in_array($id, $inserted, true)) { + $inserted[] = $id; + --$pending[$type]; + } + }; + if ($types !== []) { + add_filter('wp_insert_post_data', $data, PHP_INT_MIN, 4); + foreach (self::INSERT_ACTIONS as $action) { + add_action($action, $named, PHP_INT_MIN, 2); + } + } $handler = static function (): callable { return static function (mixed $message = ''): never { if ($message instanceof WP_Error) { @@ -359,6 +408,11 @@ class DiviThemeBuilder try { return $call(); + } catch (Throwable $e) { + if ($inserted !== []) { + throw new InsertedBeforeFailure($e->getMessage(), $inserted, $e); + } + throw $e; } finally { while (ob_get_level() > $level) { ob_end_clean(); @@ -366,6 +420,12 @@ class DiviThemeBuilder foreach (self::DIE_FILTERS as $filter) { remove_filter($filter, $handler, PHP_INT_MAX); } + if ($types !== []) { + remove_filter('wp_insert_post_data', $data, PHP_INT_MIN); + foreach (self::INSERT_ACTIONS as $action) { + remove_action($action, $named, PHP_INT_MIN); + } + } } } } diff --git a/src/Integrations/Divi/ThemeBuilder/InsertedBeforeFailure.php b/src/Integrations/Divi/ThemeBuilder/InsertedBeforeFailure.php new file mode 100644 index 0000000..279d3ba --- /dev/null +++ b/src/Integrations/Divi/ThemeBuilder/InsertedBeforeFailure.php @@ -0,0 +1,24 @@ + $postIds */ + public function __construct(string $message, public readonly array $postIds, ?Throwable $previous = null) + { + parent::__construct($message, 0, $previous); + } +} diff --git a/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php b/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php index 419a0fa..bd2a2bf 100644 --- a/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php +++ b/src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php @@ -215,10 +215,11 @@ final class TbCreateTemplateAbility implements Ability return $plan; } + /** The default template, only when the user may edit it (ruling Q19, like tb-list Q8/B11). */ private function defaultTemplate(): ?Template { foreach ($this->access->store()->liveTemplates() as $template) { - if ($template->default) { + if ($template->default && $this->access->canEditTemplate($template->id)) { return $template; } } diff --git a/src/Integrations/Divi/ThemeBuilder/TemplateBuild.php b/src/Integrations/Divi/ThemeBuilder/TemplateBuild.php index 0aeadcd..c684166 100644 --- a/src/Integrations/Divi/ThemeBuilder/TemplateBuild.php +++ b/src/Integrations/Divi/ThemeBuilder/TemplateBuild.php @@ -27,6 +27,9 @@ final class TemplateBuild /** @var list */ private array $created = []; + /** @var list Posts the last undo() could not remove (or the template still listed). */ + private array $left = []; + /** @var array{0: int, 1: int}|null Theme Builder post, template. */ private ?array $attached = null; @@ -37,7 +40,7 @@ final class TemplateBuild /** @throws RuntimeException */ public function layout(string $area, string $title): int { - $id = $this->store->insertLayout($area, $title); + $id = $this->recording(fn (): int => $this->store->insertLayout($area, $title)); $this->created[] = $id; return $id; @@ -72,7 +75,7 @@ final class TemplateBuild */ public function template(int $themeBuilderId, array $template): int { - $id = $this->store->storeTemplate($themeBuilderId, $template); + $id = $this->recording(fn (): int => $this->store->storeTemplate($themeBuilderId, $template)); $this->created[] = $id; return $id; @@ -85,6 +88,26 @@ final class TemplateBuild $this->store->attach($themeBuilderId, $templateId); } + /** + * Runs one Divi insert; posts Divi created before it failed (InsertedBeforeFailure, ruling Q19) join the build. + * + * @param callable(): int $insert + * @throws RuntimeException + */ + private function recording(callable $insert): int + { + try { + return $insert(); + } catch (InsertedBeforeFailure $e) { + foreach ($e->postIds as $id) { + if (! in_array($id, $this->created, true)) { + $this->created[] = $id; + } + } + throw $e; + } + } + /** @return list */ public function created(): array { @@ -101,7 +124,10 @@ final class TemplateBuild { $failures = $this->undo(); if ($failures !== []) { - throw $this->partial(new RuntimeException($failures[0]), ''); + throw $this->partial( + new RuntimeException(implode('; ', $failures)), + ' after refusal: ' . implode('; ', $failures) + ); } return $refusal; @@ -122,17 +148,40 @@ final class TemplateBuild ); } - /** @return list */ + /** + * Detaches, then deletes every created post newest first with its layout_type term relationships (Divi sets + * them in insert_layout; WordPress does not remove them because that taxonomy is not registered for the TB + * layout types). Notes in $left what could not be removed. + * + * @return list + */ private function undo(): array { + $this->left = []; $steps = []; if ($this->attached !== null) { [$themeBuilderId, $templateId] = $this->attached; - $steps[] = fn () => $this->store->detach($themeBuilderId, $templateId); + $steps[] = function () use ($themeBuilderId, $templateId): void { + try { + $this->store->detach($themeBuilderId, $templateId); + } catch (Throwable $e) { + $this->left[] = $templateId; + throw $e; + } + }; } foreach (array_reverse($this->created) as $id) { - $steps[] = static function () use ($id): void { - if (! wp_delete_post($id, true)) { + $steps[] = function () use ($id): void { + try { + wp_delete_object_term_relationships($id, 'layout_type'); + $deleted = wp_delete_post($id, true); + } catch (Throwable $e) { + $deleted = false; + } + if (! $deleted) { + if (! in_array($id, $this->left, true)) { + $this->left[] = $id; + } throw new RuntimeException(sprintf('post %d not deleted', $id)); } }; @@ -143,7 +192,7 @@ final class TemplateBuild private function partial(Throwable $cause, string $detail): WriteFailed { - $ids = implode(', ', $this->created); + $ids = implode(', ', $this->left); $this->log->recordPartial( $this->ability, DiviThemeBuilder::TEMPLATE, @@ -157,7 +206,7 @@ final class TemplateBuild 'Theme Builder rollback incomplete' . $detail, state: sprintf('Möglicherweise sind die Beiträge %s noch vorhanden (deaktiviert).', $ids), hint: sprintf('Im Divi Theme Builder prüfen und die Beiträge %s bei Bedarf löschen.', $ids), - details: ['created_ids' => $this->created] + details: ['left_ids' => $this->left] ); } } diff --git a/tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php b/tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php index d1c4ead..9be6ea8 100644 --- a/tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php +++ b/tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php @@ -4,13 +4,16 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder; +use Brain\Monkey\Actions; use Brain\Monkey\Filters; use Brain\Monkey\Functions; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; +use LIT\MCP\Integrations\Divi\ThemeBuilder\InsertedBeforeFailure; use LIT\MCP\Tests\Unit\TestCase; use Mockery; use RuntimeException; use WP_Error; +use WP_Post; final class DiviThemeBuilderTest extends TestCase { @@ -194,6 +197,96 @@ final class DiviThemeBuilderTest extends TestCase } } + /** + * The hooks the adapter watches while Divi runs (ruling Q19), captured so a fake Divi can fire them like + * wp_insert_post() does: wp_insert_post_data before the row, clean_post_cache after it. + * + * @return \ArrayObject "data" and "id". + */ + private static function captureInsertWatch(): \ArrayObject + { + $hooks = new \ArrayObject(['data' => null, 'id' => null]); + Filters\expectAdded('wp_insert_post_data')->whenHappen(static function (callable $callback) use ($hooks): void { + $hooks['data'] = $callback; + }); + Actions\expectAdded('clean_post_cache')->whenHappen(static function (callable $callback) use ($hooks): void { + $hooks['id'] = $callback; + }); + + return $hooks; + } + + /** + * What wp_insert_post() does around the row of post $id of $type. + * + * @param \ArrayObject $hooks + */ + private static function insert(\ArrayObject $hooks, int $id, string $type, bool $update = false): void + { + ($hooks['data'])(['post_type' => $type], [], [], $update); + ($hooks['id'])($id, new WP_Post(['ID' => $id, 'post_type' => $type])); + } + + /** Ruling Q19: a throw after Divi's own wp_insert_post() (e.g. a hook's wp_die) still names the new post. */ + public function testAThrowAfterTheLayoutInsertNamesTheNewLayout(): void + { + Functions\when('current_user_can')->justReturn(true); + $hooks = self::captureInsertWatch(); + Functions\when('et_theme_builder_insert_layout')->alias(static function () use ($hooks): int { + self::insert($hooks, 2101, 'et_body_layout'); + throw new RuntimeException('Divi called wp_die(): hook'); + }); + + try { + (new DiviThemeBuilder())->insertLayout('body', 'Person – Inhalt'); + self::fail('no exception'); + } catch (InsertedBeforeFailure $e) { + self::assertSame([2101], $e->postIds); + self::assertSame('Divi called wp_die(): hook', $e->getMessage()); + } + self::assertFalse(has_filter('wp_insert_post_data')); + self::assertFalse(has_action('clean_post_cache')); + } + + public function testAThrowAfterTheTemplateInsertNamesTheNewTemplateOnly(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('et_')->justReturn(null); + $hooks = self::captureInsertWatch(); + Functions\when('et_theme_builder_store_template')->alias(static function () use ($hooks): int { + // An existing template updated and another type inserted meanwhile are never named. + ($hooks['id'])(1066, new WP_Post(['ID' => 1066, 'post_type' => 'et_template'])); + self::insert($hooks, 1066, 'et_template', true); + self::insert($hooks, 3000, 'page'); + self::insert($hooks, 2100, 'et_template'); + ($hooks['id'])(2100, new WP_Post(['ID' => 2100, 'post_type' => 'et_template'])); + throw new RuntimeException('meta hook failed'); + }); + + try { + (new DiviThemeBuilder())->storeTemplate(1064, ['title' => 'P', 'layouts' => [], 'use_on' => [], 'exclude_from' => []]); + self::fail('no exception'); + } catch (InsertedBeforeFailure $e) { + self::assertSame([2100], $e->postIds); + } + } + + public function testAThrowBeforeAnyInsertIsAPlainFailure(): void + { + Functions\when('current_user_can')->justReturn(true); + self::captureInsertWatch(); + Functions\when('et_theme_builder_insert_layout')->alias(static function (): int { + throw new RuntimeException('early'); + }); + + try { + (new DiviThemeBuilder())->insertLayout('body', 'Inhalt'); + self::fail('no exception'); + } catch (RuntimeException $e) { + self::assertNotInstanceOf(InsertedBeforeFailure::class, $e); + } + } + /** Nothing Divi prints reaches the MCP response, on success and on failure; buffers stay balanced. */ public function testOutputOfDiviNeverLeaks(): void { diff --git a/tests/Unit/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbilityTest.php b/tests/Unit/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbilityTest.php index fb99a2f..e629b71 100644 --- a/tests/Unit/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbilityTest.php +++ b/tests/Unit/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbilityTest.php @@ -9,6 +9,7 @@ use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\SaveScope; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; +use LIT\MCP\Integrations\Divi\ThemeBuilder\InsertedBeforeFailure; use LIT\MCP\Integrations\Divi\ThemeBuilder\TbCreateTemplateAbility; use LIT\MCP\Integrations\Divi\ThemeBuilder\Template; use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions; @@ -32,6 +33,7 @@ final class TbCreateTemplateAbilityTest extends TestCase parent::setUp(); // The default template's global header 1061 is a published layout the user may edit (ruling Q8). LibraryFixtures::given([ThemeBuilderFixtures::layout(ThemeBuilderFixtures::HEADER)]); + Functions\when('wp_delete_object_term_relationships')->justReturn(null); Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path); } @@ -383,4 +385,60 @@ final class TbCreateTemplateAbilityTest extends TestCase self::assertFalse($schema['additionalProperties']); self::assertSame(TbCreateTemplateAbility::SOURCES, $schema['properties']['body']['properties']['source']['enum']); } + + /** Ruling Q19: Divi threw after inserting the template (e.g. a hook's wp_die) – that template goes too. */ + public function testATemplateInsertedBeforeDiviThrewIsRolledBack(): void + { + $store = self::store(); + $store->shouldReceive('insertLayout')->once()->andReturn(2101); + $store->shouldReceive('storeTemplate')->once()->andThrow( + new InsertedBeforeFailure('Divi called wp_die(): hook', [2100], new RuntimeException('hook')) + ); + $store->shouldReceive('attach')->never(); + $deleted = []; + self::deletions($deleted); + + try { + $this->ability($store)->execute(self::input()); + self::fail('no WriteFailed'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + self::assertSame([2100, 2101], $deleted); + } + + public function testALayoutInsertedBeforeDiviThrewIsRolledBack(): void + { + $store = self::store(); + $store->shouldReceive('insertLayout')->once()->andThrow( + new InsertedBeforeFailure('Divi called wp_die(): hook', [2101], new RuntimeException('hook')) + ); + $store->shouldReceive('storeTemplate')->never(); + $deleted = []; + self::deletions($deleted); + + $this->expectException(WriteFailed::class); + try { + $this->ability($store)->execute(self::input()); + } finally { + self::assertSame([2101], $deleted); + } + } + + /** Ruling Q19: the default template counts only when the user may edit it (like tb-list, Q8/B11). */ + public function testTheGlobalHeaderNeedsAnEditableDefaultTemplate(): void + { + LibraryFixtures::given([ThemeBuilderFixtures::layout(ThemeBuilderFixtures::HEADER)]); + Functions\when('current_user_can')->alias( + static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' + && (int) ($args[0] ?? 0) === ThemeBuilderFixtures::DEFAULT_TEMPLATE) + ); + $store = self::store(); + $store->shouldReceive('insertLayout')->never(); + + $result = $this->ability($store)->execute(self::input()); + + self::assertToolError($result, 'validation_failed'); + self::assertArrayHasKey('header.source', self::errorFields($result)); + } } diff --git a/tests/Unit/Integrations/Divi/ThemeBuilder/TemplateBuildTest.php b/tests/Unit/Integrations/Divi/ThemeBuilder/TemplateBuildTest.php index db5219e..56e8fbe 100644 --- a/tests/Unit/Integrations/Divi/ThemeBuilder/TemplateBuildTest.php +++ b/tests/Unit/Integrations/Divi/ThemeBuilder/TemplateBuildTest.php @@ -8,6 +8,7 @@ use Brain\Monkey\Functions; use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; +use LIT\MCP\Integrations\Divi\ThemeBuilder\InsertedBeforeFailure; use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateBuild; use LIT\MCP\Tests\Support\FakeChangeLog; use LIT\MCP\Tests\Support\ThemeBuilderFixtures; @@ -18,6 +19,12 @@ use WP_Post; final class TemplateBuildTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + Functions\when('wp_delete_object_term_relationships')->justReturn(null); + } + public function testRollsBackEveryCreatedPostInReverse(): void { $store = ThemeBuilderFixtures::store(); @@ -61,7 +68,7 @@ final class TemplateBuildTest extends TestCase self::fail('no WriteFailed'); } catch (WriteFailed $e) { self::assertFalse($e->rolledBack); - self::assertSame([2101], $e->details['created_ids']); + self::assertSame([2101], $e->details['left_ids']); self::assertToolError($e->toError(), 'partial_write'); } $log->assertPartials([ @@ -149,4 +156,104 @@ final class TemplateBuildTest extends TestCase $this->expectException(RuntimeException::class); $build->themeBuilder(); } + + /** Ruling Q19: posts Divi inserted before it threw are part of the build and rolled back. */ + public function testPostsInsertedBeforeDiviThrewAreRolledBack(): void + { + $store = ThemeBuilderFixtures::store(); + $store->shouldReceive('insertLayout')->once()->andReturn(2101); + $store->shouldReceive('storeTemplate')->once()->andThrow( + new InsertedBeforeFailure('Divi called wp_die(): hook', [2100], new RuntimeException('hook')) + ); + $deleted = []; + Functions\when('wp_delete_post')->alias(static function (int $id) use (&$deleted): WP_Post { + $deleted[] = $id; + + return new WP_Post(['ID' => $id]); + }); + $build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template'); + $build->layout('body', 'Inhalt'); + + try { + $build->template(1064, ['title' => 'P', 'layouts' => [], 'use_on' => [], 'exclude_from' => []]); + self::fail('no exception'); + } catch (InsertedBeforeFailure $e) { + self::assertSame([2101, 2100], $build->created()); + try { + $build->rollBack($e); + } catch (WriteFailed $failed) { + self::assertTrue($failed->rolledBack); + } + } + self::assertSame([2100, 2101], $deleted); + } + + public function testALayoutInsertedBeforeDiviThrewIsRolledBack(): void + { + $store = ThemeBuilderFixtures::store(); + $store->shouldReceive('insertLayout')->once()->andThrow( + new InsertedBeforeFailure('terms failed', [2101], new RuntimeException('terms failed')) + ); + Functions\expect('wp_delete_post')->once()->with(2101, true)->andReturn(new WP_Post(['ID' => 2101])); + $build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template'); + + try { + $build->layout('body', 'Inhalt'); + self::fail('no exception'); + } catch (InsertedBeforeFailure $e) { + $this->expectException(WriteFailed::class); + $build->rollBack($e); + } + } + + /** WordPress leaves layout_type relationships behind for TB layouts (taxonomy not registered for them). */ + public function testTheLayoutTypeTermRelationshipsGoToo(): void + { + $store = ThemeBuilderFixtures::store(); + $store->shouldReceive('insertLayout')->andReturn(2101); + $calls = []; + Functions\when('wp_delete_object_term_relationships')->alias( + static function (int $id, string $taxonomy) use (&$calls): void { + $calls[] = [$id, $taxonomy, 'terms']; + } + ); + Functions\when('wp_delete_post')->alias(static function (int $id) use (&$calls): WP_Post { + $calls[] = [$id, 'post']; + + return new WP_Post(['ID' => $id]); + }); + $build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template'); + $build->layout('body', 'Inhalt'); + + try { + $build->rollBack(new RuntimeException('later')); + } catch (WriteFailed) { + } + self::assertSame([[2101, 'layout_type', 'terms'], [2101, 'post']], $calls); + } + + /** Ruling Q19: the partial write names only what is left, and a refusal keeps every failure. */ + public function testAPartialWriteNamesOnlyThePostsLeft(): void + { + $store = ThemeBuilderFixtures::store(); + $store->shouldReceive('insertLayout')->andReturn(2101, 2102, 2103); + Functions\when('wp_delete_post')->alias( + static fn (int $id): WP_Post|false => $id === 2101 ? new WP_Post(['ID' => $id]) : false + ); + $log = FakeChangeLog::partialsOnly(); + $build = new TemplateBuild($store, $log, 'lit/tb-create-template'); + $build->layout('header', 'Kopf'); + $build->layout('body', 'Inhalt'); + $build->layout('footer', 'Fuß'); + + try { + $build->refuse(Errors::invalidInput('x', ['body.nodes' => 'x'])); + self::fail('no WriteFailed'); + } catch (WriteFailed $e) { + self::assertSame([2103, 2102], $e->details['left_ids']); + self::assertStringContainsString('post 2103 not deleted', $e->getMessage()); + self::assertStringContainsString('post 2102 not deleted', $e->getMessage()); + } + $log->assertPartials(['object_id' => '2103, 2102']); + } }