*/ private array $savedNew = []; protected function setUp(): void { 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); } /** The template 2100 as Divi would store it for input(). */ private static function stored(array $overrides = []): Template { return ThemeBuilderFixtures::template(2100, array_merge([ 'title' => 'Person', 'layouts' => [ 'header' => ['id' => 1061, 'enabled' => true], 'body' => ['id' => 2101, 'enabled' => true], 'footer' => ['id' => 0, 'enabled' => false], ], 'use_on' => ['singular:post_type:lit_person:all'], 'attached' => false, ], $overrides)); } private static function store(int $draftId = 0, ?Template $stored = null): DiviThemeBuilder { return ThemeBuilderFixtures::store( [ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)], $draftId, [], [$stored ?? self::stored()] ); } /** @param list $deniedRoles */ private function ability( DiviThemeBuilder $store, ?FakeChangeLog $log = null, array $deniedRoles = [], ?Throwable $saveFailure = null ): TbCreateTemplateAbility { $saver = Mockery::mock(DiviSaver::class); $saver->allows('saveNew')->andReturnUsing( function (int $id, string $content, SaveScope $scope) use ($saveFailure): array { if ($saveFailure !== null) { throw $saveFailure; } $this->savedNew[] = [$id, $content, $scope]; return []; } ); return new TbCreateTemplateAbility( ThemeBuilderFixtures::access($deniedRoles, true, $store), new TemplateConditions($store), DiviWriterFixture::composition(DiviWriterFixture::designReader()), $saver, $log ?? DiviWriterFixture::log(), new ThemeBuilderEdits(ThemeBuilderFixtures::access([], true, $store), fields: self::personFields()) ); } private static function personFields(): DynamicFields { $fields = Mockery::mock(DynamicFields::class); $fields->allows('reason')->andReturnUsing( static fn (string $type, string $key): ?string => $key === 'role' ? null : 'kein bekanntes Feld dieses Inhaltstyps' ); $fields->allows('note')->andReturnNull(); return $fields; } /** @return array */ private static function input(array $extra = []): array { return array_merge([ 'title' => 'Person', 'header' => ['source' => 'global'], 'body' => [ 'source' => 'new', 'nodes' => [[ 'type' => 'section', 'label' => 'Person', 'modules' => [ ['type' => 'heading', 'text' => '{{post:title}}'], ['type' => 'text', 'content' => '{{field:role}}'], ], ]], ], 'footer' => ['source' => 'hidden'], 'use_on' => ['singular:post_type:lit_person:all'], ], $extra); } private static function expectCreation(DiviThemeBuilder $store): void { $store->shouldReceive('insertLayout')->once()->with('body', 'Person – Inhalt')->andReturn(2101); $store->shouldReceive('storeTemplate')->once()->with(1064, [ 'title' => 'Person', 'layouts' => [ 'header' => ['id' => 1061, 'enabled' => true], 'body' => ['id' => 2101, 'enabled' => true], 'footer' => ['id' => 0, 'enabled' => false], ], 'use_on' => ['singular:post_type:lit_person:all'], 'exclude_from' => [], ])->andReturn(2100); } /** @param list $deleted Receives the IDs wp_delete_post() is called with. */ private static function deletions(array &$deleted): void { Functions\when('wp_delete_post')->alias(static function (int $id, bool $force) use (&$deleted): WP_Post { $deleted[] = $id; return new WP_Post(['ID' => $id]); }); } public function testNewTemplateIsDisabledAndAttached(): void { $store = self::store(); self::expectCreation($store); $store->shouldReceive('attach')->once()->with(1064, 2100); $store->shouldReceive('clearCaches')->once(); $store->shouldReceive('setEnabled')->never(); $log = DiviWriterFixture::log(); $result = $this->ability($store, $log)->execute(self::input()); self::assertTrue($result['ok']); self::assertSame(2100, $result['template_id']); self::assertFalse($result['enabled']); self::assertSame($store->template(2100, true)?->rev(), $result['template_rev']); self::assertSame(['id' => 2101, 'enabled' => true, 'source' => 'new'], $result['layouts']['body']); self::assertSame(['id' => 1061, 'enabled' => true, 'source' => 'global'], $result['layouts']['header']); self::assertSame(['id' => 0, 'enabled' => false, 'source' => 'hidden'], $result['layouts']['footer']); self::assertCount(1, $this->savedNew); [$id, $content, $scope] = $this->savedNew[0]; self::assertSame(2101, $id); self::assertSame(SaveScope::ThemeBuilderLayout, $scope); self::assertStringStartsWith('', $content); self::assertStringContainsString('custom_meta_role', $content); self::assertContains(TbCreateTemplateAbility::STALE_EDITOR, $result['warnings']); self::assertStringContainsString('deaktiviert', $result['summary']); $log->assertRecorded([ 'ability' => 'lit/tb-create-template', 'object_type' => 'et_template', 'object_id' => '2100', ]); self::assertStringContainsString('body 2101', $log->summaries()[0]); } public function testNewLayoutsWithoutNodesAndDefaultAreasStoreNoContent(): void { $stored = self::stored(['layouts' => [ 'header' => ['id' => 2101, 'enabled' => true], 'body' => ['id' => 0, 'enabled' => true], 'footer' => ['id' => 0, 'enabled' => true], ], 'use_on' => []]); $store = self::store(0, $stored); $store->shouldReceive('insertLayout')->once()->with('header', 'Eigener Kopf')->andReturn(2101); $store->shouldReceive('storeTemplate')->once()->andReturn(2100); $store->shouldReceive('attach')->once()->with(1064, 2100); $store->shouldReceive('clearCaches')->once(); $result = $this->ability($store)->execute([ 'title' => 'Person', 'header' => ['source' => 'new', 'title' => 'Eigener Kopf'], ]); self::assertTrue($result['ok']); self::assertSame([], $this->savedNew); self::assertSame(['id' => 0, 'enabled' => true, 'source' => 'default'], $result['layouts']['body']); } /** E7: an unsaved Theme Builder session would later replace the template list. */ public function testRefusesWhileTheThemeBuilderHasADraft(): void { $store = self::store(999); $store->shouldReceive('insertLayout')->never(); $store->shouldReceive('storeTemplate')->never(); $ability = $this->ability($store); self::assertToolError($ability->permission(self::input()), 'unsupported'); self::assertToolError($ability->execute(self::input()), 'unsupported'); } public function testInvalidInputCreatesNothing(): void { $store = self::store(); $store->shouldReceive('insertLayout')->never(); $result = $this->ability($store)->execute(self::input([ 'title' => '', 'body' => ['source' => 'global'], 'footer' => ['source' => 'new', 'nodes' => [['type' => 'section', 'modules' => [['type' => 'post-content']]]]], 'use_on' => ['singular:post_type:lit_person:all', 'erfunden'], ])); self::assertToolError($result, 'validation_failed'); $fields = self::errorFields($result); self::assertArrayHasKey('title', $fields); self::assertArrayHasKey('body.source', $fields); self::assertArrayHasKey('use_on.1', $fields); self::assertNotSame([], array_filter(array_keys($fields), static fn (string $key): bool => str_starts_with($key, 'footer.nodes'))); } public function testNodesOnlyWithANewLayout(): void { $store = self::store(); $store->shouldReceive('insertLayout')->never(); $result = $this->ability($store)->execute(self::input([ 'header' => ['source' => 'hidden', 'nodes' => [['type' => 'section']]], ])); self::assertToolError($result, 'validation_failed'); self::assertSame('nur mit source "new"', self::errorFields($result)['header.nodes']); } /** * Ruling Q8: a reused ("global") layout must be a published layout of the area the user may edit; missing, * trashed and not editable read the same. */ public function testTheGlobalHeaderMustBeAPublishedEditableLayout(): void { $messages = []; foreach ([[[], []], [[ThemeBuilderFixtures::layout(1061, ['post_status' => 'trash'])], []], [[ThemeBuilderFixtures::layout(1061)], ['edit_post']]] as [$posts, $denied]) { LibraryFixtures::given($posts, $denied); $store = self::store(); $store->shouldReceive('insertLayout')->never(); $result = $this->ability($store)->execute(self::input()); self::assertToolError($result, 'validation_failed'); $messages[] = self::errorFields($result)['header.source'] ?? null; } self::assertNotNull($messages[0]); self::assertSame([$messages[0], $messages[0], $messages[0]], $messages); } public function testFailedTemplateStoreDeletesTheLayouts(): void { $store = self::store(); $store->shouldReceive('insertLayout')->once()->andReturn(2101); $store->shouldReceive('storeTemplate')->once()->andThrow(new RuntimeException('Divi called wp_die(): ')); $store->shouldReceive('attach')->never(); Functions\expect('wp_delete_post')->once()->with(2101, true)->andReturn(new WP_Post(['ID' => 2101])); $this->expectException(WriteFailed::class); $this->ability($store)->execute(self::input()); } public function testAFailedLayoutSaveDeletesTheLayout(): void { $store = self::store(); $store->shouldReceive('insertLayout')->once()->andReturn(2101); $store->shouldReceive('storeTemplate')->never(); $deleted = []; self::deletions($deleted); try { $this->ability($store, null, [], new RuntimeException('kses window'))->execute(self::input()); self::fail('no WriteFailed'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); } self::assertSame([2101], $deleted); } /** Divi drops layout IDs that are no published layout of the area (theme-builder.php:1486-1496). */ public function testDiviDroppingALayoutIsRolledBack(): void { $dropped = self::stored(['layouts' => [ 'header' => ['id' => 1061, 'enabled' => true], 'body' => ['id' => 0, 'enabled' => true], 'footer' => ['id' => 0, 'enabled' => false], ]]); $store = self::store(0, $dropped); self::expectCreation($store); $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); } /** Answer F1 / ruling Q5: a template that reads back enabled is removed again, never left live. */ public function testATemplateStoredEnabledIsRolledBack(): void { $store = self::store(0, self::stored(['enabled' => true])); self::expectCreation($store); $store->shouldReceive('attach')->never(); $deleted = []; self::deletions($deleted); $this->expectException(WriteFailed::class); try { $this->ability($store)->execute(self::input()); } finally { self::assertSame([2100, 2101], $deleted); } } public function testAFailedAttachDetachesAndDeletesEverything(): void { $store = self::store(); self::expectCreation($store); $store->shouldReceive('attach')->once()->andThrow(new RuntimeException('not added')); $store->shouldReceive('detach')->once()->with(1064, 2100); $deleted = []; self::deletions($deleted); $this->expectException(WriteFailed::class); try { $this->ability($store)->execute(self::input()); } finally { self::assertSame([2100, 2101], $deleted); } } public function testACleanupThatFailsIsAPartialWrite(): void { $store = self::store(); $store->shouldReceive('insertLayout')->once()->andReturn(2101); $store->shouldReceive('storeTemplate')->once()->andThrow(new RuntimeException('store failed')); Functions\when('wp_delete_post')->justReturn(false); $log = FakeChangeLog::partialsOnly(); try { $this->ability($store, $log)->execute(self::input()); self::fail('no WriteFailed'); } catch (WriteFailed $e) { self::assertFalse($e->rolledBack); self::assertToolError($e->toError(), 'partial_write'); } $log->assertPartials(['ability' => 'lit/tb-create-template', 'object_id' => '2101']); } public function testNeedsTheThemeBuilderRole(): void { $store = self::store(); $store->shouldReceive('insertLayout')->never(); $ability = $this->ability($store, null, ['theme_builder']); self::assertToolError($ability->permission(self::input()), 'forbidden'); self::assertToolError($ability->execute(self::input()), 'forbidden'); self::assertFalse($ability->isDirect()); self::assertFalse($ability->annotations()['destructive']); } public function testNeverOffersToEnable(): void { $ability = $this->ability(self::store()); $schema = $ability->inputSchema(); self::assertArrayNotHasKey('enabled', $schema['properties']); 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)); } public function testAnUnknownFieldInTheNewBodyIsAWarning(): void { $store = self::store(); self::expectCreation($store); $store->shouldReceive('attach')->once(); $store->shouldReceive('clearCaches')->once(); $input = self::input(); $input['body']['nodes'][0]['modules'][] = ['type' => 'text', 'content' => '{{field:telefon}}']; $result = $this->ability($store)->execute($input); self::assertTrue($result['ok']); self::assertNotSame([], array_filter($result['warnings'], static fn (string $w): bool => str_contains($w, '„telefon“'))); self::assertSame([], array_filter($result['warnings'], static fn (string $w): bool => str_contains($w, '„role“'))); } }