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']); } }