alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path); } /** @param array $bodyFields */ private static function givenPosts(array $bodyFields = []): void { LibraryFixtures::given([ ThemeBuilderFixtures::templatePost(1066), ThemeBuilderFixtures::templatePost(1065), ThemeBuilderFixtures::layout(1061), ThemeBuilderFixtures::layout(1062, $bodyFields), ThemeBuilderFixtures::layout(1063), ]); } private static function ability(DiviThemeBuilder $store, ?FakeChangeLog $log = null): TbAssignAbility { return new TbAssignAbility( ThemeBuilderFixtures::access([], true, $store), new TemplateConditions($store), $log ?? DiviWriterFixture::log() ); } /** @return array */ private static function input(Template $template, array $extra = []): array { return array_merge( ['template_id' => $template->id, 'template_rev' => $template->rev(), 'use_on' => [self::PERSONS]], $extra ); } public function testSetsConditionsOfADisabledTemplateAtOnce(): void { $template = ThemeBuilderFixtures::template(1066); $store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065), $template]); $store->shouldReceive('setConditions')->once()->with(1066, [self::PERSONS], []); $store->shouldReceive('clearCaches')->never(); $result = self::ability($store)->execute(self::input($template)); self::assertTrue($result['ok']); self::assertFalse($result['live']); self::assertSame(['use_on' => ['singular:post_type:page:id:1053'], 'exclude_from' => []], $result['previous']); self::assertStringContainsString('ohne Wirkung auf die Website', $result['summary']); } /** Ruling Q9/B22: live → dry run + confirm; ruling Q3/B3: the dry run shows both lists before and after. */ public function testActiveTemplateNeedsConfirm(): void { $template = ThemeBuilderFixtures::template(1066, ['enabled' => true]); $other = ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Personen alt', 'use_on' => [self::PERSONS]]); $store = ThemeBuilderFixtures::store([$template, $other]); $calls = 0; $store->shouldReceive('setConditions')->andReturnUsing(static function () use (&$calls): void { ++$calls; }); $store->shouldReceive('clearCaches')->once(); $dry = self::ability($store)->execute(self::input($template)); self::assertToolError($dry, 'confirm_required'); self::assertSame(0, $calls); self::assertSame(['use_on' => ['Bestimmte Seiten: 1053'], 'exclude_from' => []], $dry->get_error_data()['before']); self::assertSame(['use_on' => ['Alle Personen'], 'exclude_from' => []], $dry->get_error_data()['after']); self::assertSame( [['id' => 1070, 'title' => 'Personen alt', 'conditions' => [self::PERSONS]]], $dry->get_error_data()['overlaps'] ); $result = self::ability($store)->execute(self::input($template, ['confirm' => true])); self::assertTrue($result['ok']); self::assertTrue($result['live']); self::assertStringContainsString('wirkt sofort', $result['summary']); self::assertSame(1, $calls); } /** Ruling Q3/B3: an omitted exclude_from stays as it is; an explicit [] clears it – shown in the dry run. */ public function testOmittedExcludeFromStaysAndAnEmptyListClearsIt(): void { $template = ThemeBuilderFixtures::template(1066, ['exclude_from' => ['singular:post_type:page:id:1054']]); $store = ThemeBuilderFixtures::store([$template]); $written = []; $store->shouldReceive('setConditions')->andReturnUsing( static function (int $id, array $useOn, array $excludeFrom) use (&$written): void { $written[] = $excludeFrom; } ); self::ability($store)->execute(self::input($template)); self::ability($store)->execute(self::input($template, ['exclude_from' => []])); self::ability($store)->execute(self::input($template, ['exclude_from' => ['homepage']])); self::assertSame([['singular:post_type:page:id:1054'], [], ['homepage']], $written); $live = ThemeBuilderFixtures::template(1066, ['enabled' => true, 'exclude_from' => ['singular:post_type:page:id:1054']]); $dry = self::ability(ThemeBuilderFixtures::store([$live]))->execute(self::input($live, ['exclude_from' => []])); self::assertToolError($dry, 'confirm_required'); self::assertSame(['Bestimmte Seiten: 1054'], $dry->get_error_data()['before']['exclude_from']); self::assertSame([], $dry->get_error_data()['after']['exclude_from']); self::assertStringContainsString('Ausnahmen', self::clientError($dry)['message']); } /** Ruling Q8/B9: other live templates of the same family are named as possible overlaps. */ public function testDryRunNamesPossibleOverlaps(): void { $template = ThemeBuilderFixtures::template(1066, ['enabled' => true]); $pages = ThemeBuilderFixtures::template(1070, [ 'enabled' => true, 'title' => 'Alle Seiten', 'use_on' => ['singular:post_type:page:all'], ]); $store = ThemeBuilderFixtures::store([$template, $pages]); $dry = self::ability($store)->execute(self::input($template, ['use_on' => ['homepage']])); self::assertSame([], $dry->get_error_data()['overlaps']); self::assertSame( [['id' => 1070, 'title' => 'Alle Seiten', 'conditions' => ['singular:post_type:page:all']]], $dry->get_error_data()['possible_overlaps'] ); } public function testReattachKeepsTheTemplateDisabledAndUnmarksItsLayouts(): void { $detached = ThemeBuilderFixtures::template(1066, [ 'enabled' => true, 'attached' => false, 'marked_unused' => '2026-09-20 08:00:00', ]); $store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065)], 0, [$detached]); $refused = self::ability($store)->execute(self::input($detached)); self::assertToolError($refused, 'validation_failed'); self::assertArrayHasKey('reattach', self::errorFields($refused)); self::assertStringContainsString('2026-09-20', self::clientError($refused)['message']); $store->shouldReceive('setEnabled')->once()->with(1066, false); $store->shouldReceive('attach')->once()->with(1064, 1066); $unmarked = []; $store->shouldReceive('unmarkUnused')->andReturnUsing(static function (int $id) use (&$unmarked): void { $unmarked[] = $id; }); $store->shouldReceive('setConditions')->once()->with(1066, [self::PERSONS], []); $log = DiviWriterFixture::log(); $result = self::ability($store, $log)->execute(self::input($detached, ['reattach' => true])); self::assertTrue($result['ok']); self::assertSame([1066, 1061, 1062, 1063], $unmarked); self::assertStringContainsString('wieder zugeordnet', $log->summaries()[0]); } /** Ruling Q8/B10: a layout Divi trashed meanwhile blocks the reattach; nothing is written. */ public function testReattachNeedsPublishedLayouts(): void { self::givenPosts(['post_status' => 'trash']); $detached = ThemeBuilderFixtures::template(1066, ['attached' => false]); $store = ThemeBuilderFixtures::store([], 0, [$detached]); foreach (['setEnabled', 'attach', 'unmarkUnused', 'setConditions'] as $method) { $store->shouldReceive($method)->never(); } $refused = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($detached, ['reattach' => true])); self::assertToolError($refused, 'validation_failed'); self::assertSame(['layouts.body'], array_keys(self::errorFields($refused))); } public function testFailedReattachDetachesAndRestoresTheFlag(): void { $detached = ThemeBuilderFixtures::template(1066, ['enabled' => true, 'attached' => false]); $store = ThemeBuilderFixtures::store([], 0, [$detached]); $steps = []; $store->shouldReceive('setEnabled')->andReturnUsing(static function (int $id, bool $on) use (&$steps): void { $steps[] = 'enabled:' . ($on ? '1' : '0'); }); $store->shouldReceive('attach')->andReturnUsing(static function () use (&$steps): void { $steps[] = 'attach'; }); $store->shouldReceive('unmarkUnused'); $store->shouldReceive('setConditions')->andReturnUsing(static function (int $id, array $useOn) use (&$steps): void { $steps[] = 'conditions:' . implode(',', $useOn); if ($useOn === [self::PERSONS]) { throw new RuntimeException('add_post_meta failed'); } }); $store->shouldReceive('detach')->once()->with(1064, 1066)->andReturnUsing(static function () use (&$steps): void { $steps[] = 'detach'; }); try { self::ability($store, FakeChangeLog::forbidden())->execute(self::input($detached, ['reattach' => true])); self::fail('no WriteFailed'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); } self::assertSame( ['enabled:0', 'attach', 'conditions:' . self::PERSONS, 'conditions:singular:post_type:page:id:1053', 'detach', 'enabled:1'], $steps ); } public function testFailureRestoresThePreviousConditions(): void { $template = ThemeBuilderFixtures::template(1066); $store = ThemeBuilderFixtures::store([$template]); $calls = []; $store->shouldReceive('setConditions')->andReturnUsing(static function (int $id, array $useOn) use (&$calls): void { $calls[] = $useOn; if (count($calls) === 1) { throw new RuntimeException('add_post_meta failed'); } }); try { self::ability($store, FakeChangeLog::forbidden())->execute(self::input($template)); self::fail('no WriteFailed'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); } self::assertSame([[self::PERSONS], ['singular:post_type:page:id:1053']], $calls); } public function testFailedRestoreIsAPartialWrite(): void { $template = ThemeBuilderFixtures::template(1066); $store = ThemeBuilderFixtures::store([$template]); $store->shouldReceive('setConditions')->andThrow(new RuntimeException('database gone')); $log = FakeChangeLog::partialsOnly(); try { self::ability($store, $log)->execute(self::input($template)); self::fail('no WriteFailed'); } catch (WriteFailed $e) { self::assertFalse($e->rolledBack); } $log->assertPartials(['ability' => 'lit/tb-assign', 'object_type' => 'et_template', 'object_id' => '1066']); } public function testChangeLogKeepsThePreviousConditions(): void { $template = ThemeBuilderFixtures::template(1066, ['exclude_from' => ['homepage']]); $store = ThemeBuilderFixtures::store([$template]); $store->shouldReceive('setConditions'); $log = DiviWriterFixture::log(); $result = self::ability($store, $log)->execute(self::input($template)); self::assertStringContainsString('singular:post_type:page:id:1053', $log->summaries()[0]); self::assertStringContainsString('homepage', $log->summaries()[0]); self::assertSame(['use_on' => ['singular:post_type:page:id:1053'], 'exclude_from' => ['homepage']], $result['previous']); } public function testInvalidConditionsAndMissingUseOnWriteNothing(): void { $template = ThemeBuilderFixtures::template(1066); $store = ThemeBuilderFixtures::store([$template]); $store->shouldReceive('setConditions')->never(); self::assertToolError(self::ability($store)->execute(self::input($template, ['use_on' => ['erfunden']])), 'validation_failed'); self::assertToolError( self::ability($store)->execute(self::input($template, ['exclude_from' => ['erfunden']])), 'validation_failed' ); $noList = self::input($template); unset($noList['use_on']); self::assertToolError(self::ability($store)->execute($noList), 'validation_failed'); self::assertTrue(self::ability($store)->annotations()['destructive']); self::assertFalse(self::ability($store)->isDirect()); } public function testPermissionUsesTheSameGate(): void { $template = ThemeBuilderFixtures::template(1066); $store = ThemeBuilderFixtures::store([$template]); self::assertTrue(self::ability($store)->permission(self::input($template))); self::assertToolError(self::ability($store)->permission(self::input($template, ['template_rev' => 'x'])), 'rev_mismatch'); self::assertToolError(self::ability($store)->permission(self::input($template, ['template_id' => 4711])), 'not_found'); } }