Files

260 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
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;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
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();
$store->shouldReceive('insertLayout')->andReturn(2101, 2102);
$store->shouldReceive('storeTemplate')->andReturn(2100);
$store->shouldReceive('attach')->once()->with(1064, 2100);
$store->shouldReceive('detach')->once()->with(1064, 2100);
$deleted = [];
Functions\when('wp_delete_post')->alias(static function (int $id, bool $force) use (&$deleted): WP_Post {
$deleted[] = [$id, $force];
return new WP_Post(['ID' => $id]);
});
$build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template');
$build->layout('header', 'Kopf');
$build->layout('body', 'Inhalt');
$build->template(1064, ['title' => 'Person', 'layouts' => [], 'use_on' => [], 'exclude_from' => []]);
$build->attach(1064, 2100);
try {
$build->rollBack(new RuntimeException('Divi dropped a layout'));
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
self::assertSame([[2100, true], [2102, true], [2101, true]], $deleted);
}
/** Ruling Q4/B4: the partial write is logged as a partial row naming the posts left behind. */
public function testAFailedDeleteIsAPartialWrite(): void
{
$store = ThemeBuilderFixtures::store();
$store->shouldReceive('insertLayout')->andReturn(2101);
Functions\when('wp_delete_post')->justReturn(false);
$log = FakeChangeLog::partialsOnly();
$build = new TemplateBuild($store, $log, 'lit/tb-create-template');
$build->layout('body', 'Inhalt');
try {
$build->rollBack(new RuntimeException('store failed'));
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertSame([2101], $e->details['left_ids']);
self::assertToolError($e->toError(), 'partial_write');
}
$log->assertPartials([
'ability' => 'lit/tb-create-template',
'object_type' => 'et_template',
'object_id' => '2101',
]);
}
public function testARefusalAfterTheInsertRemovesTheNewPostsAndReturnsTheRefusal(): void
{
$store = ThemeBuilderFixtures::store();
$store->shouldReceive('insertLayout')->andReturn(2101);
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');
$build->layout('body', 'Inhalt');
$refusal = Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.', ['body.nodes' => 'x']);
self::assertSame($refusal, $build->refuse($refusal));
}
public function testAFailedCleanupAfterARefusalIsAPartialWrite(): void
{
$store = ThemeBuilderFixtures::store();
$store->shouldReceive('insertLayout')->andReturn(2101);
Functions\when('wp_delete_post')->justReturn(null);
$log = FakeChangeLog::partialsOnly();
$build = new TemplateBuild($store, $log, 'lit/tb-create-template');
$build->layout('body', 'Inhalt');
try {
$build->refuse(Errors::invalidInput('x', ['body.nodes' => 'x']));
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
}
$log->assertPartials(['object_id' => '2101']);
}
/** Ruling Q8/B12: a live Theme Builder post Divi creates for this build is part of it (all or nothing). */
public function testALiveThemeBuilderPostCreatedHereIsRolledBackToo(): void
{
$store = Mockery::mock(DiviThemeBuilder::class);
$store->shouldReceive('liveId')->once()->with(false)->andReturn(0);
$store->shouldReceive('liveId')->once()->with(true)->andReturn(3000);
$store->shouldReceive('insertLayout')->andReturn(2101);
$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');
self::assertSame(3000, $build->themeBuilder());
self::assertSame([2101, 3000], $build->created());
try {
$build->rollBack(new RuntimeException('later failure'));
} catch (WriteFailed) {
}
self::assertSame([3000, 2101], $deleted);
}
public function testAnExistingLiveThemeBuilderPostIsNeverDeleted(): void
{
$store = Mockery::mock(DiviThemeBuilder::class);
$store->shouldReceive('liveId')->once()->with(false)->andReturn(1064);
$store->shouldReceive('liveId')->with(true)->never();
Functions\expect('wp_delete_post')->never();
$build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template');
self::assertSame(1064, $build->themeBuilder());
self::assertSame([], $build->created());
$this->expectException(WriteFailed::class);
$build->rollBack(new RuntimeException('later failure'));
}
public function testNoLiveThemeBuilderPostThrows(): void
{
$store = Mockery::mock(DiviThemeBuilder::class);
$store->shouldReceive('liveId')->andReturn(0);
$build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template');
$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']);
}
}