fix: tb-create-template rolls back posts Divi inserted before failing (ruling Q19)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 16:23:01 +02:00
co-authored by Claude Opus 5.5
parent 42b9b384d5
commit 2827fd4bd8
7 changed files with 406 additions and 14 deletions
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\ThemeBuilder; namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use RuntimeException; use RuntimeException;
use Throwable;
use WP_Error; use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -60,6 +61,9 @@ class DiviThemeBuilder
'wp_die_xml_handler', 'wp_die_xmlrpc_handler', '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. */ /** "header", "body" or "footer" for a layout post type, else null. */
public static function areaOf(string $postType): ?string 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); $type = self::LAYOUT_TYPES[$area] ?? throw new RuntimeException('unknown Theme Builder area ' . $area);
$this->assertCanWrite('et_theme_builder_insert_layout'); $this->assertCanWrite('et_theme_builder_insert_layout');
$id = $this->guarded( $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) { if (! is_int($id) || $id <= 0) {
$reason = $id instanceof WP_Error ? $id->get_error_message() : 'no id'; $reason = $id instanceof WP_Error ? $id->get_error_message() : 'no id';
@@ -198,7 +203,10 @@ class DiviThemeBuilder
'use_on' => $template['use_on'], 'use_on' => $template['use_on'],
'exclude_from' => $template['exclude_from'], '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) { if (! is_int($id) || $id <= 0) {
throw new RuntimeException('et_theme_builder_store_template stored no template'); 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 * 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 * 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. * 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<string> $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 { $handler = static function (): callable {
return static function (mixed $message = ''): never { return static function (mixed $message = ''): never {
if ($message instanceof WP_Error) { if ($message instanceof WP_Error) {
@@ -359,6 +408,11 @@ class DiviThemeBuilder
try { try {
return $call(); return $call();
} catch (Throwable $e) {
if ($inserted !== []) {
throw new InsertedBeforeFailure($e->getMessage(), $inserted, $e);
}
throw $e;
} finally { } finally {
while (ob_get_level() > $level) { while (ob_get_level() > $level) {
ob_end_clean(); ob_end_clean();
@@ -366,6 +420,12 @@ class DiviThemeBuilder
foreach (self::DIE_FILTERS as $filter) { foreach (self::DIE_FILTERS as $filter) {
remove_filter($filter, $handler, PHP_INT_MAX); 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);
}
}
} }
} }
} }
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
use RuntimeException;
use Throwable;
defined('ABSPATH') || exit;
/**
* Divi failed after its own wp_insert_post() already created posts (a throwing hook, a wp_die() turned into an
* exception by DiviThemeBuilder::guarded()): $postIds are the new posts of the expected types, so the caller can
* roll them back (ruling Q19). The message is the original failure's.
*/
final class InsertedBeforeFailure extends RuntimeException
{
/** @param list<int> $postIds */
public function __construct(string $message, public readonly array $postIds, ?Throwable $previous = null)
{
parent::__construct($message, 0, $previous);
}
}
@@ -215,10 +215,11 @@ final class TbCreateTemplateAbility implements Ability
return $plan; return $plan;
} }
/** The default template, only when the user may edit it (ruling Q19, like tb-list Q8/B11). */
private function defaultTemplate(): ?Template private function defaultTemplate(): ?Template
{ {
foreach ($this->access->store()->liveTemplates() as $template) { foreach ($this->access->store()->liveTemplates() as $template) {
if ($template->default) { if ($template->default && $this->access->canEditTemplate($template->id)) {
return $template; return $template;
} }
} }
@@ -27,6 +27,9 @@ final class TemplateBuild
/** @var list<int> */ /** @var list<int> */
private array $created = []; private array $created = [];
/** @var list<int> 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. */ /** @var array{0: int, 1: int}|null Theme Builder post, template. */
private ?array $attached = null; private ?array $attached = null;
@@ -37,7 +40,7 @@ final class TemplateBuild
/** @throws RuntimeException */ /** @throws RuntimeException */
public function layout(string $area, string $title): int 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; $this->created[] = $id;
return $id; return $id;
@@ -72,7 +75,7 @@ final class TemplateBuild
*/ */
public function template(int $themeBuilderId, array $template): int 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; $this->created[] = $id;
return $id; return $id;
@@ -85,6 +88,26 @@ final class TemplateBuild
$this->store->attach($themeBuilderId, $templateId); $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<int> */ /** @return list<int> */
public function created(): array public function created(): array
{ {
@@ -101,7 +124,10 @@ final class TemplateBuild
{ {
$failures = $this->undo(); $failures = $this->undo();
if ($failures !== []) { if ($failures !== []) {
throw $this->partial(new RuntimeException($failures[0]), ''); throw $this->partial(
new RuntimeException(implode('; ', $failures)),
' after refusal: ' . implode('; ', $failures)
);
} }
return $refusal; return $refusal;
@@ -122,17 +148,40 @@ final class TemplateBuild
); );
} }
/** @return list<string> */ /**
* 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<string>
*/
private function undo(): array private function undo(): array
{ {
$this->left = [];
$steps = []; $steps = [];
if ($this->attached !== null) { if ($this->attached !== null) {
[$themeBuilderId, $templateId] = $this->attached; [$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) { foreach (array_reverse($this->created) as $id) {
$steps[] = static function () use ($id): void { $steps[] = function () use ($id): void {
if (! wp_delete_post($id, true)) { 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)); throw new RuntimeException(sprintf('post %d not deleted', $id));
} }
}; };
@@ -143,7 +192,7 @@ final class TemplateBuild
private function partial(Throwable $cause, string $detail): WriteFailed private function partial(Throwable $cause, string $detail): WriteFailed
{ {
$ids = implode(', ', $this->created); $ids = implode(', ', $this->left);
$this->log->recordPartial( $this->log->recordPartial(
$this->ability, $this->ability,
DiviThemeBuilder::TEMPLATE, DiviThemeBuilder::TEMPLATE,
@@ -157,7 +206,7 @@ final class TemplateBuild
'Theme Builder rollback incomplete' . $detail, 'Theme Builder rollback incomplete' . $detail,
state: sprintf('Möglicherweise sind die Beiträge %s noch vorhanden (deaktiviert).', $ids), 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), 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]
); );
} }
} }
@@ -4,13 +4,16 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder; namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
use Brain\Monkey\Actions;
use Brain\Monkey\Filters; use Brain\Monkey\Filters;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\Divi\ThemeBuilder\InsertedBeforeFailure;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use Mockery; use Mockery;
use RuntimeException; use RuntimeException;
use WP_Error; use WP_Error;
use WP_Post;
final class DiviThemeBuilderTest extends TestCase 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<string, callable|null> "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<string, callable|null> $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. */ /** Nothing Divi prints reaches the MCP response, on success and on failure; buffers stay balanced. */
public function testOutputOfDiviNeverLeaks(): void public function testOutputOfDiviNeverLeaks(): void
{ {
@@ -9,6 +9,7 @@ use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\SaveScope; use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; 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\TbCreateTemplateAbility;
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template; use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions; use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
@@ -32,6 +33,7 @@ final class TbCreateTemplateAbilityTest extends TestCase
parent::setUp(); parent::setUp();
// The default template's global header 1061 is a published layout the user may edit (ruling Q8). // The default template's global header 1061 is a published layout the user may edit (ruling Q8).
LibraryFixtures::given([ThemeBuilderFixtures::layout(ThemeBuilderFixtures::HEADER)]); 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); 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::assertFalse($schema['additionalProperties']);
self::assertSame(TbCreateTemplateAbility::SOURCES, $schema['properties']['body']['properties']['source']['enum']); 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));
}
} }
@@ -8,6 +8,7 @@ use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\Divi\ThemeBuilder\InsertedBeforeFailure;
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateBuild; use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateBuild;
use LIT\MCP\Tests\Support\FakeChangeLog; use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\ThemeBuilderFixtures; use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
@@ -18,6 +19,12 @@ use WP_Post;
final class TemplateBuildTest extends TestCase final class TemplateBuildTest extends TestCase
{ {
protected function setUp(): void
{
parent::setUp();
Functions\when('wp_delete_object_term_relationships')->justReturn(null);
}
public function testRollsBackEveryCreatedPostInReverse(): void public function testRollsBackEveryCreatedPostInReverse(): void
{ {
$store = ThemeBuilderFixtures::store(); $store = ThemeBuilderFixtures::store();
@@ -61,7 +68,7 @@ final class TemplateBuildTest extends TestCase
self::fail('no WriteFailed'); self::fail('no WriteFailed');
} catch (WriteFailed $e) { } catch (WriteFailed $e) {
self::assertFalse($e->rolledBack); self::assertFalse($e->rolledBack);
self::assertSame([2101], $e->details['created_ids']); self::assertSame([2101], $e->details['left_ids']);
self::assertToolError($e->toError(), 'partial_write'); self::assertToolError($e->toError(), 'partial_write');
} }
$log->assertPartials([ $log->assertPartials([
@@ -149,4 +156,104 @@ final class TemplateBuildTest extends TestCase
$this->expectException(RuntimeException::class); $this->expectException(RuntimeException::class);
$build->themeBuilder(); $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']);
}
} }