alias(static function (string $what) use (&$cleared): void { $cleared[] = $what; }); (new DiviThemeBuilder())->clearCaches(); self::assertSame([['all', 'all', true]], \ET_Core_PageResource::$removed); self::assertSame(['all'], $cleared); } /** Audit L4: a template is listed when any Theme Builder post's _et_template row names it. */ public function testIsListedAnywhereAsksEveryThemeBuilderPost(): void { $db = Mockery::mock(\wpdb::class)->makePartial(); $db->postmeta = 'wp_postmeta'; $sql = []; $db->allows('get_var')->andReturnUsing(static function (string $query) use (&$sql): ?string { $sql[] = $query; return str_contains($query, "'1066'") ? '1' : null; }); $GLOBALS['wpdb'] = $db; try { self::assertTrue((new DiviThemeBuilder())->isListedAnywhere(1066)); self::assertFalse((new DiviThemeBuilder())->isListedAnywhere(2394)); } finally { unset($GLOBALS['wpdb']); } self::assertStringContainsString("meta_key = '_et_template'", $sql[0]); self::assertStringContainsString('LIMIT 1', $sql[0]); } /** F17/F18: Divi wp_die()s without edit_others_posts – the adapter never gets there. */ public function testWithoutEditOthersPostsDiviIsNeverCalled(): void { Functions\when('current_user_can')->justReturn(false); Functions\expect('et_theme_builder_insert_layout')->never(); Functions\expect('et_theme_builder_store_template')->never(); foreach ([ static fn () => (new DiviThemeBuilder())->insertLayout('body', 'Person – Inhalt'), static fn () => (new DiviThemeBuilder())->storeTemplate(1064, [ 'title' => 'Person', 'layouts' => [], 'use_on' => [], 'exclude_from' => [], ]), ] as $call) { try { $call(); self::fail('Divi was called without edit_others_posts'); } catch (RuntimeException $e) { self::assertStringContainsString('edit_others_posts', $e->getMessage()); } } } /** While Divi runs, every wp_die handler throws instead (defense in depth); afterwards WordPress' own again. */ public function testDiviIsOnlyCalledWithTheRightAndWithoutWpDie(): void { Functions\when('current_user_can')->justReturn(true); $seen = []; Functions\when('et_theme_builder_insert_layout')->alias(static function (array $options) use (&$seen): int { $seen = ['options' => $options, 'guarded' => has_filter('wp_die_json_handler') !== false]; return 2101; }); self::assertSame(2101, (new DiviThemeBuilder())->insertLayout('body', 'Person – Inhalt')); self::assertSame(['post_type' => 'et_body_layout', 'post_title' => 'Person – Inhalt'], $seen['options']); self::assertTrue($seen['guarded'], 'wp_die throws while Divi runs'); self::assertFalse(has_filter('wp_die_json_handler'), 'and only then'); } public function testStoreTemplateIsAlwaysDisabledAndNeverDefault(): void { Functions\when('current_user_can')->justReturn(true); Functions\when('et_')->justReturn(null); $call = []; Functions\when('et_theme_builder_store_template')->alias( static function (int $tb, array $template, bool $allowDefault) use (&$call): int { $call = [$tb, $template, $allowDefault]; return 2100; } ); $id = (new DiviThemeBuilder())->storeTemplate(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' => [], ]); self::assertSame(2100, $id); self::assertSame(1064, $call[0]); self::assertSame('0', $call[1]['enabled']); self::assertSame('0', $call[1]['default']); self::assertSame('0', $call[1]['autogenerated_title']); self::assertFalse($call[2], 'allow_default'); self::assertSame(['id' => 0, 'enabled' => false], $call[1]['layouts']['footer']); } /** Mirrors et_theme_builder_api_save(): backup option, append, remove the backup (api.php:223-243). */ public function testAttachAppendsLikeDivisSave(): void { Functions\when('get_post_meta')->justReturn(['1065', '1066']); Functions\expect('update_option')->once()->with('et_tb_templates_backup_1064', ['1065', '1066']); Functions\expect('add_post_meta')->once()->with(1064, '_et_template', 2100)->andReturn(501); Functions\expect('delete_option')->once()->with('et_tb_templates_backup_1064'); (new DiviThemeBuilder())->attach(1064, 2100); } public function testSetConditionsReplacesBothListsWithoutDuplicates(): void { $calls = []; Functions\when('delete_post_meta')->alias(static function (int $id, string $key) use (&$calls): bool { $calls[] = ['delete', $key]; return true; }); Functions\when('add_post_meta')->alias(static function (int $id, string $key, string $value) use (&$calls): int { $calls[] = ['add', $key, $value]; return 1; }); (new DiviThemeBuilder())->setConditions( 2100, ['singular:post_type:lit_person:all', 'singular:post_type:lit_person:all'], ['singular:post_type:lit_person:id:12'] ); self::assertSame([ ['delete', '_et_use_on'], ['add', '_et_use_on', 'singular:post_type:lit_person:all'], ['delete', '_et_exclude_from'], ['add', '_et_exclude_from', 'singular:post_type:lit_person:id:12'], ], $calls); } /** Ruling B16: PHP turns the key '404' into an int; the label and the object type/value are read. */ public function testFlatConditionsReadLabelsAndObjectTypes(): void { Functions\when('wp_strip_all_tags')->returnArg(); Functions\when('et_theme_builder_get_flat_template_settings_options')->justReturn([ '404' => ['id' => '404', 'label' => '404-Seite'], 'singular:post_type:page:id:' => [ 'label' => 'Bestimmte Seiten', 'options' => ['type' => 'post_type', 'value' => 'page'], ], ]); $flat = (new DiviThemeBuilder())->flatConditions(); self::assertSame(['label' => '404-Seite', 'type' => null, 'value' => null], $flat['404']); self::assertSame('post_type', $flat['singular:post_type:page:id:']['type']); self::assertSame('page', $flat['singular:post_type:page:id:']['value']); } private const DIE_FILTERS = [ 'wp_die_handler', 'wp_die_ajax_handler', 'wp_die_json_handler', 'wp_die_jsonp_handler', 'wp_die_xml_handler', 'wp_die_xmlrpc_handler', ]; /** A wp_die() inside Divi becomes an exception; every handler filter is gone again afterwards. */ public function testWpDieInsideDiviThrowsAndEveryHandlerIsRestored(): void { Functions\when('current_user_can')->justReturn(true); $factory = null; Filters\expectAdded('wp_die_handler')->once()->whenHappen( static function (callable $callback, int $priority) use (&$factory): void { self::assertSame(PHP_INT_MAX, $priority); $factory = $callback; } ); $guarded = []; Functions\when('et_theme_builder_insert_layout')->alias(static function () use (&$factory, &$guarded): int { foreach (self::DIE_FILTERS as $filter) { $guarded[$filter] = has_filter($filter) !== false; } // What wp_die() does: apply_filters( 'wp_die_handler', '_default_wp_die_handler' ), then call it. $handler = $factory('_default_wp_die_handler'); $handler(new WP_Error('divi', 'Sorry, you are not allowed'), '', []); return 2101; }); try { (new DiviThemeBuilder())->insertLayout('body', 'Person – Inhalt'); self::fail('wp_die did not throw'); } catch (RuntimeException $e) { self::assertSame('Divi called wp_die(): Sorry, you are not allowed', $e->getMessage()); } self::assertSame(array_fill_keys(self::DIE_FILTERS, true), $guarded); foreach (self::DIE_FILTERS as $filter) { self::assertFalse(has_filter($filter), $filter . ' restored'); } } /** * 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 "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 $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 { Functions\when('current_user_can')->justReturn(true); $level = ob_get_level(); $calls = 0; Functions\when('et_theme_builder_insert_layout')->alias(static function () use (&$calls): int { echo 'Notice: Divi'; ob_start(); echo 'unclosed buffer'; if (++$calls === 2) { throw new RuntimeException('Divi failed'); } return 2101; }); $this->expectOutputString(''); self::assertSame(2101, (new DiviThemeBuilder())->insertLayout('body', 'Person – Inhalt')); self::assertSame($level, ob_get_level()); try { (new DiviThemeBuilder())->insertLayout('body', 'Person – Inhalt'); self::fail('no exception'); } catch (RuntimeException $e) { self::assertSame('Divi failed', $e->getMessage()); } self::assertSame($level, ob_get_level()); self::assertFalse(has_filter('wp_die_handler')); } public function testARefusedLayoutNamesDivisReason(): void { Functions\when('current_user_can')->justReturn(true); Functions\when('et_theme_builder_insert_layout')->justReturn(new WP_Error('db', 'Datenbankfehler')); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Datenbankfehler'); (new DiviThemeBuilder())->insertLayout('header', 'Kopf'); } public function testAnUnknownAreaIsRefusedBeforeDivi(): void { Functions\when('current_user_can')->justReturn(true); Functions\expect('et_theme_builder_insert_layout')->never(); $this->expectException(RuntimeException::class); (new DiviThemeBuilder())->insertLayout('sidebar', 'Seite'); } /** E15: a Divi function that moved is reported, never called (no fatal error mid-write). */ public function testMissingDiviFunctionsAreRefusedBeforeWriting(): void { Functions\when('current_user_can')->justReturn(true); Functions\expect('et_theme_builder_insert_layout')->never(); Functions\expect('et_theme_builder_store_template')->never(); $store = Mockery::mock(DiviThemeBuilder::class)->makePartial(); $store->allows('missing')->andReturn(['et_theme_builder_insert_layout', 'et_']); foreach ([ static fn () => $store->insertLayout('body', 'Person – Inhalt'), static fn () => $store->storeTemplate(1064, [ 'title' => 'Person', 'layouts' => [], 'use_on' => [], 'exclude_from' => [], ]), ] as $call) { try { $call(); self::fail('Divi was called although it is missing'); } catch (RuntimeException $e) { self::assertStringContainsString('missing', $e->getMessage()); } } } /** Befund 10 / storage format: _et_template holds each ID once, as a string row. */ public function testAttachNeverListsATemplateTwice(): void { Functions\when('get_post_meta')->justReturn(['1065', '2100']); Functions\expect('update_option')->never(); Functions\expect('add_post_meta')->never(); (new DiviThemeBuilder())->attach(1064, 2100); self::assertTrue(true); } public function testAFailedAttachThrowsAndLeavesNoBackup(): void { Functions\when('get_post_meta')->justReturn(['1065']); Functions\when('update_option')->justReturn(true); Functions\when('add_post_meta')->justReturn(false); Functions\expect('delete_option')->once()->with('et_tb_templates_backup_1064'); $this->expectException(RuntimeException::class); (new DiviThemeBuilder())->attach(1064, 2100); } public function testDetachRemovesTheStoredRow(): void { Functions\expect('delete_post_meta')->once()->with(1064, '_et_template', '2100')->andReturn(true); (new DiviThemeBuilder())->detach(1064, 2100); } public function testSetEnabledStoresDivisFlagAndReadsItBack(): void { Functions\expect('update_post_meta')->once()->with(2100, '_et_enabled', '1'); Functions\when('get_post_meta')->justReturn('1'); (new DiviThemeBuilder())->setEnabled(2100, true); } public function testSetEnabledThrowsWhenTheFlagDidNotStick(): void { Functions\when('update_post_meta')->justReturn(false); Functions\when('get_post_meta')->justReturn('1'); $this->expectException(RuntimeException::class); (new DiviThemeBuilder())->setEnabled(2100, false); } public function testTemplateReadsStatusAndUnusedMark(): void { Functions\when('et_theme_builder_get_template')->alias(static fn (int $id): array => $id === 2394 ? [ 'id' => 2394, 'title' => '[Fixture 3b] Person', 'default' => false, 'enabled' => false, 'layouts' => ['header' => ['id' => 1061, 'enabled' => true]], 'use_on' => [], 'exclude_from' => [], ] : []); Functions\when('get_post_meta')->justReturn('2026-09-27 12:47:02'); Functions\when('get_post_status')->justReturn('publish'); $template = (new DiviThemeBuilder())->template(2394, false); self::assertNotNull($template); self::assertSame('2026-09-27 12:47:02', $template->markedUnused); self::assertFalse($template->attached); self::assertNull((new DiviThemeBuilder())->template(7, true)); } }