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:
@@ -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<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. */
|
||||
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\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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user