From 64fc439cb2860e7fb0a336880588af8e16b86c58 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 16:33:29 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lit/tb-activate=20=E2=80=93=20the=20exp?= =?UTF-8?q?licit=20switch,=20always=20a=20dry=20run=20first=20(answer=20F1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - only _et_enabled; dry run in both directions lists conditions, exclusions, layouts, overlaps and possible overlaps (B9); confirm: true switches - enabling needs conditions and published layouts (B10); a detached template needs reattach first; the default template is refused - the off-switch reaches an attached, enabled template outside publish, e.g. in the trash (ruling Q16); result names the fast way back - every switch in the change log; failed switch rolled back Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/ThemeBuilder/TbActivateAbility.php | 275 ++++++++++++++++++ .../Divi/ThemeBuilder/ThemeBuilderAccess.php | 39 ++- .../ThemeBuilder/ThemeBuilderIntegration.php | 1 + .../Integrations/Divi/DiviIntegrationTest.php | 2 +- .../ThemeBuilder/TbActivateAbilityTest.php | 246 ++++++++++++++++ .../ThemeBuilder/ThemeBuilderAccessTest.php | 39 +++ 6 files changed, 598 insertions(+), 4 deletions(-) create mode 100644 src/Integrations/Divi/ThemeBuilder/TbActivateAbility.php create mode 100644 tests/Unit/Integrations/Divi/ThemeBuilder/TbActivateAbilityTest.php diff --git a/src/Integrations/Divi/ThemeBuilder/TbActivateAbility.php b/src/Integrations/Divi/ThemeBuilder/TbActivateAbility.php new file mode 100644 index 0000000..b67dc62 --- /dev/null +++ b/src/Integrations/Divi/ThemeBuilder/TbActivateAbility.php @@ -0,0 +1,275 @@ + 'object', + 'properties' => [ + 'template_id' => ['type' => 'integer', 'minimum' => 1], + 'template_rev' => ['type' => 'string', 'description' => 'template_rev aus lit/tb-list.'], + 'enabled' => ['type' => 'boolean'], + 'confirm' => ['type' => 'boolean'], + ], + 'required' => ['template_id', 'template_rev', 'enabled'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema([ + 'template_id' => ['type' => 'integer'], + 'template_rev' => ['type' => 'string'], + 'enabled' => ['type' => 'boolean'], + 'live' => ['type' => 'boolean'], + ], ['template_id', 'template_rev', 'enabled', 'live']); + } + + public function annotations(): array + { + return Annotations::destructive(true); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $template = $this->access->changeableTemplate($input, ($input['enabled'] ?? null) === false); + + return $template instanceof WP_Error ? $template : true; + } + + /** @throws WriteFailed */ + public function execute(array $input): mixed + { + $enabled = $input['enabled'] ?? null; + $template = $this->access->changeableTemplate($input, $enabled === false); + if ($template instanceof WP_Error) { + return $template; + } + + if (! is_bool($enabled)) { + return Errors::invalidInput('enabled fehlt; nichts wurde gespeichert.', ['enabled' => 'true oder false']); + } + + if (! $template->attached) { + return Errors::invalidInput( + sprintf('Vorlage %d ist im Theme Builder nicht zugeordnet; nichts wurde gespeichert.', $template->id), + ['template_id' => 'zuerst mit lit/tb-assign und reattach: true wieder zuordnen'] + ); + } + + if ($template->enabled === $enabled) { + return $this->result($template, sprintf( + 'Keine Änderung – die Vorlage „%s“ ist bereits %s.', + $template->title, + $enabled ? 'aktiv' : 'deaktiviert' + ), null); + } + + if ($enabled) { + $refusal = $this->activationRefusal($template); + if ($refusal !== null) { + return $refusal; + } + } + + if (($input['confirm'] ?? false) !== true) { + return $this->dryRun($template, $enabled); + } + + return $this->switch($template, $enabled); + } + + /** Enabling needs conditions and published layouts (B10). */ + private function activationRefusal(Template $template): ?WP_Error + { + if ($template->useOn === []) { + return Errors::invalidInput( + 'Ohne Bedingungen gilt eine Vorlage nirgends; zuerst mit lit/tb-assign Bedingungen setzen. Nichts ' + . 'wurde gespeichert.', + ['template_id' => 'keine Bedingungen (use_on)'] + ); + } + + return $this->access->layoutsRefusal($template); + } + + private function dryRun(Template $template, bool $enabled): WP_Error + { + $labels = array_map($this->conditions->label(...), $template->useOn); + $message = $enabled + ? sprintf( + 'Aktivieren: Die Vorlage „%s“ gilt danach sofort für Besucher (%s). Überschneidungen mit anderen ' + . 'aktiven Vorlagen unter overlaps/possible_overlaps. Nichts wurde gespeichert (Probelauf).', + $template->title, + implode('; ', $labels) + ) + : sprintf( + 'Deaktivieren: Die Vorlage „%s“%s gilt danach nicht mehr (%s); dort zeigt Divi wieder eine andere ' + . 'Vorlage, die Standardvorlage oder das Theme. Nichts wurde gespeichert (Probelauf).', + $template->title, + $template->status === 'publish' ? '' : sprintf(' (im Papierkorb oder Status „%s“)', $template->status), + implode('; ', $labels) + ); + $live = $enabled ? $this->access->store()->liveTemplates() : []; + + return Errors::confirmRequired($message, [ + 'use_on' => $labels, + 'exclude_from' => array_map($this->conditions->label(...), $template->excludeFrom), + 'layouts' => $template->layouts, + 'overlaps' => Template::overlaps($live, $template->id, $template->useOn), + 'possible_overlaps' => Template::possibleOverlaps($live, $template->id, $template->useOn), + ]); + } + + /** + * @return array + * @throws WriteFailed + */ + private function switch(Template $template, bool $enabled): array + { + $store = $this->access->store(); + try { + $store->setEnabled($template->id, $enabled); + } catch (Throwable $e) { + $this->undo($template, $e); + } + + $warnings = []; + FollowUp::run( + static fn () => $store->clearCaches(), + 'theme builder cache', + 'Der Cache des Theme Builders konnte nicht geleert werden; die Änderung ist gespeichert, wirkt aber evtl. ' + . 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).', + $warnings + ); + $logId = $this->log->record( + $this->name(), + DiviThemeBuilder::TEMPLATE, + (string) $template->id, + sprintf( + 'Theme Builder: Vorlage „%s“ %s (Bedingungen: %s)', + $template->title, + $enabled ? 'aktiviert' : 'deaktiviert', + $template->useOn === [] ? 'keine' : implode(', ', $template->useOn) + ) + ); + $after = $store->template($template->id, true) ?? $template; + + $summary = $enabled + ? sprintf( + 'Vorlage „%s“ aktiviert – sie gilt jetzt für Besucher. Schneller Weg zurück: lit/tb-activate mit ' + . 'enabled: false (template_rev aus dieser Antwort).', + $template->title + ) + : sprintf('Vorlage „%s“ deaktiviert – sie gilt nicht mehr für Besucher.', $template->title); + + return $this->result($after, $summary, $logId, $enabled, $warnings); + } + + /** @throws WriteFailed Always. */ + private function undo(Template $template, Throwable $cause): never + { + $store = $this->access->store(); + $failures = Rollback::attempt(static fn () => $store->setEnabled($template->id, $template->enabled)); + if ($failures === []) { + throw WriteFailed::rolledBack(self::REASON, $cause, 'tb-activate failed: ' . $cause->getMessage()); + } + + $this->log->recordPartial( + $this->name(), + DiviThemeBuilder::TEMPLATE, + (string) $template->id, + sprintf('Theme Builder: Umschalten von „%s“ nicht zurückgenommen (%s)', $template->title, $failures[0]) + ); + throw WriteFailed::partial( + self::REASON, + $cause, + 'tb-activate rollback: ' . $failures[0], + $template->id, + hint: 'Mit lit/tb-list prüfen, ob die Vorlage aktiv ist.' + ); + } + + /** + * @param list $warnings + * @return array + */ + private function result( + Template $template, + string $summary, + ?int $logId, + ?bool $enabled = null, + array $warnings = [] + ): array { + return ToolResult::ok($summary) + ->with('template_id', $template->id) + ->with('template_rev', $template->rev()) + ->with('enabled', $enabled ?? $template->enabled) + ->with('live', $enabled ?? $template->isLive()) + ->warnings($warnings) + ->logId($logId) + ->urls(null, (string) admin_url('admin.php?page=et_theme_builder')) + ->toArray(); + } +} diff --git a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php index 23bf484..3281519 100644 --- a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php +++ b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php @@ -157,15 +157,27 @@ class ThemeBuilderAccess /** * A template the Theme Builder tools may change (lit/tb-assign, lit/tb-activate): rights and group gate * (template(), a template the user may not edit reads as missing – ruling Q18), no Theme Builder draft (E7), - * never the default template (E8), the current template_rev. + * never the default template (E8), the current template_rev. $offSwitch (lit/tb-activate with enabled: false, + * ruling Q16): also a template visitors see although it is not published (e.g. in the trash – Divi renders + * every attached, enabled template, B8), so it can always be switched off; any other unpublished template + * still reads as missing. * * @param array $input template_id, template_rev */ - public function changeableTemplate(array $input): Template|WP_Error + public function changeableTemplate(array $input, bool $offSwitch = false): Template|WP_Error { $post = $this->template($input['template_id'] ?? null, true); if ($post instanceof WP_Error) { - return $post; + $id = $offSwitch && Errors::codeOf($post) === 'not_found' + ? $this->liveUnpublished($input['template_id'] ?? null) + : null; + if ($id === null) { + return $post; + } + + $gate = $this->writableGate(); + + return $gate ?? $this->checkedTemplate($id, $input); } return $this->checkedTemplate((int) $post->ID, $input); @@ -262,6 +274,27 @@ class ThemeBuilderAccess ); } + /** + * ID of a template that is not published but live (listed by the live Theme Builder post and enabled) and the + * user may edit; null otherwise – the raw lookup of the off-switch (ruling Q16). + */ + private function liveUnpublished(mixed $templateId): ?int + { + $id = PostGuard::id($templateId); + $post = $id === null ? null : get_post($id); + if ( + ! $post instanceof WP_Post + || $post->post_type !== DiviThemeBuilder::TEMPLATE + || (string) $post->post_status === 'publish' + || ! $this->canEditTemplate((int) $post->ID) + || ! in_array((int) $post->ID, $this->store->templateIds($this->store->liveId()), true) + ) { + return null; + } + + return $this->store->template((int) $post->ID, true)?->isLive() === true ? (int) $post->ID : null; + } + /** * The draft, default and template_rev checks of changeableTemplate() for template $id (rights already checked). * diff --git a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderIntegration.php b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderIntegration.php index 8c4439b..e5420bc 100644 --- a/src/Integrations/Divi/ThemeBuilder/ThemeBuilderIntegration.php +++ b/src/Integrations/Divi/ThemeBuilder/ThemeBuilderIntegration.php @@ -53,6 +53,7 @@ final class ThemeBuilderIntegration implements Integration new TbListAbility($this->access, $conditions), new TbCreateTemplateAbility($this->access, $conditions, $this->composition, $this->saver, $this->log), new TbAssignAbility($this->access, $conditions, $this->log), + new TbActivateAbility($this->access, $conditions, $this->log), ]; } } diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 4de492e..e85fe34 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -78,7 +78,7 @@ final class DiviIntegrationTest extends TestCase self::assertSame('Divi Theme Builder', $group->label()); self::assertFalse($group->isAvailable(), 'no Divi classes in unit tests'); self::assertSame( - ['lit/tb-list', 'lit/tb-create-template', 'lit/tb-assign'], + ['lit/tb-list', 'lit/tb-create-template', 'lit/tb-assign', 'lit/tb-activate'], array_map(static fn (Ability $ability): string => $ability->name(), $group->abilities()) ); foreach ($group->abilities() as $ability) { diff --git a/tests/Unit/Integrations/Divi/ThemeBuilder/TbActivateAbilityTest.php b/tests/Unit/Integrations/Divi/ThemeBuilder/TbActivateAbilityTest.php new file mode 100644 index 0000000..ae9ad51 --- /dev/null +++ b/tests/Unit/Integrations/Divi/ThemeBuilder/TbActivateAbilityTest.php @@ -0,0 +1,246 @@ +alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path); + } + + /** + * @param array $templateFields + * @param array $bodyFields + */ + private static function givenPosts(array $templateFields = [], array $bodyFields = []): void + { + LibraryFixtures::given([ + ThemeBuilderFixtures::templatePost(1066, $templateFields), + ThemeBuilderFixtures::templatePost(1065), + ThemeBuilderFixtures::layout(1061), + ThemeBuilderFixtures::layout(1062, $bodyFields), + ThemeBuilderFixtures::layout(1063), + ]); + } + + private static function ability(DiviThemeBuilder $store, ?FakeChangeLog $log = null): TbActivateAbility + { + return new TbActivateAbility( + ThemeBuilderFixtures::access([], true, $store), + new TemplateConditions($store), + $log ?? DiviWriterFixture::log() + ); + } + + /** @return array */ + private static function input(Template $template, bool $enabled, bool $confirm = false): array + { + return array_filter([ + 'template_id' => $template->id, + 'template_rev' => $template->rev(), + 'enabled' => $enabled, + 'confirm' => $confirm ?: null, + ], static fn (mixed $value): bool => $value !== null); + } + + public function testActivationIsAlwaysADryRunFirst(): void + { + $template = ThemeBuilderFixtures::template(1066); + $other = ThemeBuilderFixtures::template(1070, [ + 'enabled' => true, + 'title' => 'Seite 1053', + 'use_on' => ['singular:post_type:page:id:1053'], + ]); + $pages = ThemeBuilderFixtures::template(1071, [ + 'enabled' => true, + 'title' => 'Alle Seiten', + 'use_on' => ['singular:post_type:page:all'], + ]); + $store = ThemeBuilderFixtures::store([$template, $other, $pages]); + $store->shouldReceive('setEnabled')->never(); + + $dry = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($template, true)); + + self::assertToolError($dry, 'confirm_required'); + self::assertStringContainsString('Aktivieren', self::clientError($dry)['message']); + self::assertSame(['Bestimmte Seiten: 1053'], $dry->get_error_data()['use_on']); + self::assertSame([], $dry->get_error_data()['exclude_from']); + self::assertSame(1070, $dry->get_error_data()['overlaps'][0]['id']); + self::assertSame(1071, $dry->get_error_data()['possible_overlaps'][0]['id']); + self::assertSame(1062, $dry->get_error_data()['layouts']['body']['id']); + } + + public function testConfirmedActivationSwitchesOnlyTheFlag(): void + { + $template = ThemeBuilderFixtures::template(1066); + $store = ThemeBuilderFixtures::store([$template]); + $store->shouldReceive('setEnabled')->once()->with(1066, true); + $store->shouldReceive('clearCaches')->once(); + $store->shouldReceive('setConditions')->never(); + $store->shouldReceive('attach')->never(); + $log = DiviWriterFixture::log(); + + $result = self::ability($store, $log)->execute(self::input($template, true, true)); + + self::assertTrue($result['ok']); + self::assertTrue($result['enabled']); + self::assertTrue($result['live']); + self::assertStringContainsString('aktiviert', $result['summary']); + self::assertStringContainsString('enabled: false', $result['summary'], 'the way back is named'); + self::assertSame(4711, $result['log_id']); + self::assertStringContainsString('aktiviert', $log->summaries()[0]); + } + + public function testDeactivationAlsoNeedsConfirm(): void + { + $template = ThemeBuilderFixtures::template(1066, ['enabled' => true]); + $store = ThemeBuilderFixtures::store([$template]); + $store->shouldReceive('setEnabled')->once()->with(1066, false); + $store->shouldReceive('clearCaches')->once(); + + $dry = self::ability($store)->execute(self::input($template, false)); + self::assertToolError($dry, 'confirm_required'); + self::assertStringContainsString('Deaktivieren', self::clientError($dry)['message']); + self::assertSame([], $dry->get_error_data()['overlaps']); + + $result = self::ability($store)->execute(self::input($template, false, true)); + self::assertTrue($result['ok']); + self::assertFalse($result['enabled']); + self::assertFalse($result['live']); + } + + /** Ruling Q16: an attached, enabled template in the trash is still rendered – the off-switch reaches it. */ + public function testDeactivatesALiveTemplateInTheTrash(): void + { + self::givenPosts(['post_status' => 'trash']); + $trashed = ThemeBuilderFixtures::template(1066, ['enabled' => true, 'status' => 'trash']); + $store = ThemeBuilderFixtures::store([$trashed]); + $store->shouldReceive('setEnabled')->once()->with(1066, false); + $store->shouldReceive('clearCaches')->once(); + + self::assertTrue(self::ability($store)->permission(self::input($trashed, false))); + self::assertToolError(self::ability($store)->permission(self::input($trashed, true)), 'not_found'); + self::assertToolError(self::ability($store)->execute(self::input($trashed, true, true)), 'not_found'); + $dry = self::ability($store)->execute(self::input($trashed, false)); + self::assertToolError($dry, 'confirm_required'); + self::assertStringContainsString('Papierkorb', self::clientError($dry)['message']); + self::assertTrue(self::ability($store)->execute(self::input($trashed, false, true))['ok']); + } + + /** Ruling Q8/B10: enabling needs published layouts; switching off never does. */ + public function testActivationNeedsPublishedLayoutsDeactivationDoesNot(): void + { + self::givenPosts([], ['post_status' => 'trash']); + $off = ThemeBuilderFixtures::template(1066); + $store = ThemeBuilderFixtures::store([$off]); + $store->shouldReceive('setEnabled')->never(); + $refused = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($off, true)); + self::assertToolError($refused, 'validation_failed'); + self::assertSame(['layouts.body'], array_keys(self::errorFields($refused))); + + $on = ThemeBuilderFixtures::template(1066, ['enabled' => true]); + $store = ThemeBuilderFixtures::store([$on]); + $store->shouldReceive('setEnabled')->once()->with(1066, false); + $store->shouldReceive('clearCaches'); + self::assertTrue(self::ability($store)->execute(self::input($on, false, true))['ok']); + } + + public function testRefusesTemplatesWithoutConditionsOrDetached(): void + { + $empty = ThemeBuilderFixtures::template(1066, ['use_on' => []]); + $store = ThemeBuilderFixtures::store([$empty]); + $store->shouldReceive('setEnabled')->never(); + self::assertToolError(self::ability($store)->execute(self::input($empty, true, true)), 'validation_failed'); + + $detached = ThemeBuilderFixtures::template(1066, ['attached' => false]); + $store = ThemeBuilderFixtures::store([], 0, [$detached]); + $store->shouldReceive('setEnabled')->never(); + $refused = self::ability($store)->execute(self::input($detached, true, true)); + self::assertToolError($refused, 'validation_failed'); + self::assertStringContainsString('reattach', (string) json_encode(self::errorFields($refused))); + } + + public function testNoChangeWhenAlreadyInThatState(): void + { + $template = ThemeBuilderFixtures::template(1066); + $store = ThemeBuilderFixtures::store([$template]); + $store->shouldReceive('setEnabled')->never(); + + $result = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($template, false)); + + self::assertStringContainsString('Keine Änderung', $result['summary']); + self::assertTrue(self::ability($store)->annotations()['destructive']); + self::assertFalse(self::ability($store)->isDirect()); + } + + public function testMissingEnabledAndTheDefaultTemplateAreRefused(): void + { + $template = ThemeBuilderFixtures::template(1066); + $store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065), $template]); + $store->shouldReceive('setEnabled')->never(); + $input = self::input($template, true, true); + unset($input['enabled']); + + self::assertToolError(self::ability($store)->execute($input), 'validation_failed'); + self::assertToolError( + self::ability($store)->execute(self::input(ThemeBuilderFixtures::template(1065), true, true)), + 'unsupported' + ); + } + + public function testAFailedSwitchIsRolledBack(): void + { + $template = ThemeBuilderFixtures::template(1066); + $store = ThemeBuilderFixtures::store([$template]); + $calls = []; + $store->shouldReceive('setEnabled')->andReturnUsing(static function (int $id, bool $on) use (&$calls): void { + $calls[] = $on; + if ($on) { + throw new RuntimeException('_et_enabled not stored'); + } + }); + $store->shouldReceive('clearCaches')->never(); + + try { + self::ability($store, FakeChangeLog::forbidden())->execute(self::input($template, true, true)); + self::fail('no WriteFailed'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + } + self::assertSame([true, false], $calls); + } + + public function testAFailedRollbackIsAPartialWrite(): void + { + $template = ThemeBuilderFixtures::template(1066); + $store = ThemeBuilderFixtures::store([$template]); + $store->shouldReceive('setEnabled')->andThrow(new RuntimeException('database gone')); + $log = FakeChangeLog::partialsOnly(); + + try { + self::ability($store, $log)->execute(self::input($template, true, true)); + self::fail('no WriteFailed'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + } + $log->assertPartials(['ability' => 'lit/tb-activate', 'object_type' => 'et_template', 'object_id' => '1066']); + } +} diff --git a/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php b/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php index 2218f2a..3e63f24 100644 --- a/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php +++ b/tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php @@ -202,4 +202,43 @@ final class ThemeBuilderAccessTest extends TestCase self::assertToolError($hidden, 'not_found'); self::assertSame(self::clientError($missing), self::clientError($hidden)); } + + /** + * Ruling Q16: Divi renders an attached, enabled template whatever its post status (B8) – the off-switch + * (offSwitch: true) reaches it in the trash; everything else about it reads like a missing template. + */ + public function testTheOffSwitchReachesALiveTemplateOutsidePublish(): void + { + $trashed = ThemeBuilderFixtures::template(1066, ['enabled' => true, 'status' => 'trash']); + $input = ['template_id' => 1066, 'template_rev' => $trashed->rev()]; + LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066, ['post_status' => 'trash'])]); + $access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$trashed])); + + self::assertSame($trashed, $access->changeableTemplate($input, true)); + $missing = $access->changeableTemplate($input); + self::assertToolError($missing, 'not_found'); + + $notLive = [ + ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([], 0, [], [$trashed])), + ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ + ThemeBuilderFixtures::template(1066, ['status' => 'trash']), + ])), + ThemeBuilderFixtures::access(['theme_builder'], true, ThemeBuilderFixtures::store([$trashed])), + ]; + foreach ($notLive as $index => $other) { + $refused = $other->changeableTemplate($input, true); + self::assertToolError($refused, 'not_found', (string) $index); + self::assertSame(self::clientError($missing), self::clientError($refused), (string) $index); + } + + self::assertToolError( + ThemeBuilderFixtures::access([], false, ThemeBuilderFixtures::store([$trashed]))->changeableTemplate($input, true), + 'forbidden' + ); + self::assertToolError( + ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$trashed])) + ->changeableTemplate(['template_id' => 1066, 'template_rev' => 'alt'], true), + 'rev_mismatch' + ); + } }