From 407d8f967f78edb0f07faca3c00308968e404978 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 16 Jul 2026 06:56:31 +0200 Subject: [PATCH 1/3] Modernize `AnnouncementForm` to ipl based `RepositoryForm` --- .../controllers/AnnouncementsController.php | 110 ++++++++---- .../forms/Announcement/AnnouncementForm.php | 156 ++++++++---------- .../views/scripts/announcements/new.phtml | 6 + .../views/scripts/announcements/remove.phtml | 6 + .../views/scripts/announcements/update.phtml | 6 + 5 files changed, 170 insertions(+), 114 deletions(-) create mode 100644 application/views/scripts/announcements/new.phtml create mode 100644 application/views/scripts/announcements/remove.phtml create mode 100644 application/views/scripts/announcements/update.phtml diff --git a/application/controllers/AnnouncementsController.php b/application/controllers/AnnouncementsController.php index 12df103fc6..c9885fde35 100644 --- a/application/controllers/AnnouncementsController.php +++ b/application/controllers/AnnouncementsController.php @@ -5,12 +5,18 @@ namespace Icinga\Controllers; +use GuzzleHttp\Psr7\ServerRequest; use Icinga\Exception\NotFoundError; use Icinga\Forms\Announcement\AcknowledgeAnnouncementForm; use Icinga\Forms\Announcement\AnnouncementForm; +use Icinga\Repository\RepositoryMode; use Icinga\Web\Announcement\AnnouncementIniRepository; use Icinga\Web\Controller; -use Icinga\Web\Url; +use Icinga\Web\Notification; +use Icinga\Web\Session; +use ipl\Web\Url; +use ipl\Html\Contract\Form; +use Throwable; class AnnouncementsController extends Controller { @@ -61,45 +67,106 @@ public function indexAction() /** * Create an announcement */ - public function newAction() + public function newAction(): void { $this->assertPermission('application/announcements'); - $form = $this->prepareForm()->add(); - $form->handleRequest(); - $this->renderForm($form, $this->translate('New Announcement')); + $this->getTabs()->add('new-announcement', [ + 'active' => true, + 'label' => $this->translate('New Announcement'), + 'title' => $this->translate('Add a new announcement'), + 'url' => Url::fromRequest(), + ]); + + $form = (new AnnouncementForm(new AnnouncementIniRepository(), RepositoryMode::Insert)) + ->setCsrfCounterMeasureId(Session::getSession()->getId()) + ->setRedirectUrl(Url::fromPath('announcements')) + ->on(Form::ON_SUBMIT, function (AnnouncementForm $form): void { + Notification::success($this->translate('Announcement created')); + $this->redirectNow($form->getRedirectUrl()); + }) + ->on(Form::ON_ERROR, function (Throwable $_, AnnouncementForm $_form): void { + Notification::error($this->translate('Failed to create announcement')); + }) + ->handleRequest(ServerRequest::fromGlobals()); + + $this->view->form = $form; } /** * Update an announcement */ - public function updateAction() + public function updateAction(): void { $this->assertPermission('application/announcements'); - $form = $this->prepareForm()->edit($this->params->getRequired('id')); + $this->getTabs()->add('update-announcement', [ + 'active' => true, + 'label' => $this->translate('Update Announcement'), + 'title' => $this->translate('Update an announcement'), + 'url' => Url::fromRequest(), + ]); + + $form = (new AnnouncementForm( + new AnnouncementIniRepository(), + RepositoryMode::Update, + $this->params->getRequired('id') + )) + ->setCsrfCounterMeasureId(Session::getSession()->getId()) + ->setRedirectUrl(Url::fromPath('announcements')) + ->on(Form::ON_SUBMIT, function (AnnouncementForm $form): void { + Notification::success($this->translate('Announcement updated')); + $this->redirectNow($form->getRedirectUrl()); + }) + ->on(Form::ON_ERROR, function (Throwable $_, AnnouncementForm $_form): void { + Notification::error($this->translate('Failed to update announcement')); + }); + try { - $form->handleRequest(); - } catch (NotFoundError $_) { + $form->handleRequest(ServerRequest::fromGlobals()); + } catch (NotFoundError) { $this->httpNotFound($this->translate('Announcement not found')); } - $this->renderForm($form, $this->translate('Update Announcement')); + + $this->view->form = $form; } /** * Remove an announcement */ - public function removeAction() + public function removeAction(): void { $this->assertPermission('application/announcements'); - $form = $this->prepareForm()->remove($this->params->getRequired('id')); + $this->getTabs()->add('remove-announcement', [ + 'active' => true, + 'label' => $this->translate('Remove Announcement'), + 'title' => $this->translate('Remove an announcement'), + 'url' => Url::fromRequest(), + ]); + + $form = (new AnnouncementForm( + new AnnouncementIniRepository(), + RepositoryMode::Delete, + $this->params->getRequired('id') + )) + ->setCsrfCounterMeasureId(Session::getSession()->getId()) + ->setRedirectUrl(Url::fromPath('announcements')) + ->on(Form::ON_SUBMIT, function (AnnouncementForm $form): void { + Notification::success($this->translate('Announcement removed')); + $this->redirectNow($form->getRedirectUrl()); + }) + ->on(Form::ON_ERROR, function (Throwable $_, AnnouncementForm $_form): void { + Notification::error($this->translate('Failed to remove announcement')); + }); + try { - $form->handleRequest(); - } catch (NotFoundError $_) { + $form->handleRequest(ServerRequest::fromGlobals()); + } catch (NotFoundError) { $this->httpNotFound($this->translate('Announcement not found')); } - $this->renderForm($form, $this->translate('Remove Announcement')); + + $this->view->form = $form; } public function acknowledgeAction() @@ -109,17 +176,4 @@ public function acknowledgeAction() $form = new AcknowledgeAnnouncementForm(); $form->handleRequest(); } - - /** - * Assert permission admin and return a prepared RepositoryForm - * - * @return AnnouncementForm - */ - protected function prepareForm() - { - $form = new AnnouncementForm(); - return $form - ->setRepository(new AnnouncementIniRepository()) - ->setRedirectUrl(Url::fromPath('announcements')); - } } diff --git a/application/forms/Announcement/AnnouncementForm.php b/application/forms/Announcement/AnnouncementForm.php index bbe14a6a4e..a8790b31fe 100644 --- a/application/forms/Announcement/AnnouncementForm.php +++ b/application/forms/Announcement/AnnouncementForm.php @@ -6,16 +6,42 @@ namespace Icinga\Forms\Announcement; use DateTime; +use Icinga\Application\Icinga; use Icinga\Authentication\Auth; use Icinga\Data\Filter\Filter; -use Icinga\Forms\RepositoryForm; +use Icinga\Repository\Repository; +use Icinga\Repository\RepositoryMode; +use Icinga\Web\Form\RepositoryForm; /** * Create, update and delete announcements */ class AnnouncementForm extends RepositoryForm { - protected function fetchEntry() + /** + * Create a new AnnouncementForm + * + * @param Repository $repository The repository to work with + * @param RepositoryMode $mode How to interact with the repository + * @param ?string $identifier The id of the announcement to handle + */ + public function __construct(Repository $repository, RepositoryMode $mode, ?string $identifier = null) + { + parent::__construct($repository, $mode, $identifier); + $this->setAttribute('name', 'repo_form_announcement'); + } + + /** + * Fetch and transform the stored announcement into form-ready values + * + * In addition to fetching the raw entry, converts the stored `start` and + * `end` Unix timestamps into {@see DateTime} objects, as expected by the + * respective `localDateTime` elements. + * + * @return object|false The transformed announcement, or false if no + * matching entry exists + */ + protected function fetchEntry(): object|false { $entry = parent::fetchEntry(); if ($entry !== false) { @@ -30,108 +56,66 @@ protected function fetchEntry() return $entry; } - /** - * {@inheritDoc} - */ - protected function createInsertElements(array $formData) + protected function assembleCommonElements(): void { - $this->addElement( - 'text', - 'author', - [ - 'disabled' => ! $this->getRequest()->isApiRequest(), - 'required' => true, - 'value' => Auth::getInstance()->getUser()->getUsername() - ] - ); - $this->addElement( - 'textarea', - 'message', - [ - 'description' => $this->translate('The message to display to users'), - 'label' => $this->translate('Message'), - 'required' => true - ] - ); - $this->addElement( - 'dateTimePicker', - 'start', - [ - 'description' => $this->translate('The time to display the announcement from'), - 'label' => $this->translate('Start'), - 'placeholder' => new DateTime('tomorrow'), - 'required' => true - ] - ); - $this->addElement( - 'dateTimePicker', - 'end', - [ - 'description' => $this->translate('The time to display the announcement until'), - 'label' => $this->translate('End'), - 'placeholder' => new DateTime('tomorrow +1day'), - 'required' => true - ] - ); + $this->addElement('text', 'author', [ + 'disabled' => ! Icinga::app()->getRequest()->isApiRequest(), + 'required' => true, + 'value' => Auth::getInstance()->getUser()->getUsername(), + ]); - $this->setTitle($this->translate('Create a new announcement')); - $this->setSubmitLabel($this->translate('Create')); - } - /** - * {@inheritDoc} - */ - protected function createUpdateElements(array $formData) - { - $this->createInsertElements($formData); - $this->setTitle(sprintf($this->translate('Edit announcement %s'), $this->getIdentifier())); - $this->setSubmitLabel($this->translate('Save')); + $this->addElement('textarea', 'message', [ + 'description' => $this->translate('The message to display to users'), + 'label' => $this->translate('Message'), + 'required' => true, + ]); + + $this->addElement('localDateTime', 'start', [ + 'description' => $this->translate('The time to display the announcement from'), + 'label' => $this->translate('Start'), + 'value' => (new DateTime('tomorrow')), + 'required' => true, + ]); + + $this->addElement('localDateTime', 'end', [ + 'description' => $this->translate('The time to display the announcement until'), + 'label' => $this->translate('End'), + 'value' => (new DateTime('tomorrow +1day')), + 'required' => true, + ]); } - /** - * {@inheritDoc} - */ - protected function createDeleteElements(array $formData) + protected function assembleInsertElements(): void { - $this->setTitle(sprintf($this->translate('Remove announcement %s?'), $this->getIdentifier())); - $this->setSubmitLabel($this->translate('Confirm Removal')); - $this->setAttrib('class', 'icinga-controls'); + $this->assembleCommonElements(); + $this->addElement('submit', 'submit_add', ['label' => $this->translate('Create')]); } - /** - * {@inheritDoc} - */ - protected function createFilter() + protected function assembleUpdateElements(): void { - return Filter::where('id', $this->getIdentifier()); + $this->assembleCommonElements(); + $this->addElement('submit', 'submit_update', ['label' => $this->translate('Save')]); } - /** - * {@inheritDoc} - */ - protected function getInsertMessage($success) + protected function assembleDeleteElements(): void { - return $success - ? $this->translate('Announcement created') - : $this->translate('Failed to create announcement'); + $this->addElement('submit', 'submit_remove', ['label' => $this->translate('Confirm Removal')]); } - /** - * {@inheritDoc} - */ - protected function getUpdateMessage($success) + protected function createFilter(): Filter { - return $success - ? $this->translate('Announcement updated') - : $this->translate('Failed to update announcement'); + return Filter::where('id', $this->getIdentifier()); } /** - * {@inheritDoc} + * Get the id of the announcement to handle + * + * @return ?string Narrower than the inherited contract, as this form + * accepts string identifiers only. Null only in + * {@see RepositoryMode::Insert} mode, where none is required. */ - protected function getDeleteMessage($success) + public function getIdentifier(): ?string { - return $success - ? $this->translate('Announcement removed') - : $this->translate('Failed to remove announcement'); + return $this->identifier; } } diff --git a/application/views/scripts/announcements/new.phtml b/application/views/scripts/announcements/new.phtml new file mode 100644 index 0000000000..13a8ed9ed1 --- /dev/null +++ b/application/views/scripts/announcements/new.phtml @@ -0,0 +1,6 @@ +
+ +
+
+ +
diff --git a/application/views/scripts/announcements/remove.phtml b/application/views/scripts/announcements/remove.phtml new file mode 100644 index 0000000000..13a8ed9ed1 --- /dev/null +++ b/application/views/scripts/announcements/remove.phtml @@ -0,0 +1,6 @@ +
+ +
+
+ +
diff --git a/application/views/scripts/announcements/update.phtml b/application/views/scripts/announcements/update.phtml new file mode 100644 index 0000000000..13a8ed9ed1 --- /dev/null +++ b/application/views/scripts/announcements/update.phtml @@ -0,0 +1,6 @@ +
+ +
+
+ +
From 8e888f3d9f4afa7539497c51a9dfb8232055d273 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 23 Jul 2026 11:05:58 +0200 Subject: [PATCH 2/3] Modernize to `CompatController` Now we don't have to use view scripts anymore. Use `setTitleTab()` to set the tab and the title respectively. This differs from the earlier behavior where the title was always `Announcements`. --- .../controllers/AnnouncementsController.php | 48 ++++--------------- .../views/scripts/announcements/new.phtml | 6 --- .../views/scripts/announcements/remove.phtml | 6 --- .../views/scripts/announcements/update.phtml | 6 --- 4 files changed, 9 insertions(+), 57 deletions(-) delete mode 100644 application/views/scripts/announcements/new.phtml delete mode 100644 application/views/scripts/announcements/remove.phtml delete mode 100644 application/views/scripts/announcements/update.phtml diff --git a/application/controllers/AnnouncementsController.php b/application/controllers/AnnouncementsController.php index c9885fde35..19fc35777e 100644 --- a/application/controllers/AnnouncementsController.php +++ b/application/controllers/AnnouncementsController.php @@ -11,36 +11,21 @@ use Icinga\Forms\Announcement\AnnouncementForm; use Icinga\Repository\RepositoryMode; use Icinga\Web\Announcement\AnnouncementIniRepository; -use Icinga\Web\Controller; use Icinga\Web\Notification; use Icinga\Web\Session; +use ipl\Web\Compat\CompatController; use ipl\Web\Url; use ipl\Html\Contract\Form; use Throwable; -class AnnouncementsController extends Controller +class AnnouncementsController extends CompatController { - public function init() - { - $this->view->title = $this->translate('Announcements'); - - parent::init(); - } - /** * List all announcements */ public function indexAction() { - $this->getTabs()->add( - 'announcements', - [ - 'active' => true, - 'label' => $this->translate('Announcements'), - 'title' => $this->translate('List All Announcements'), - 'url' => Url::fromPath('announcements') - ] - ); + $this->addTitleTab($this->translate('Announcements')); $announcements = (new AnnouncementIniRepository()) ->select([ @@ -71,12 +56,7 @@ public function newAction(): void { $this->assertPermission('application/announcements'); - $this->getTabs()->add('new-announcement', [ - 'active' => true, - 'label' => $this->translate('New Announcement'), - 'title' => $this->translate('Add a new announcement'), - 'url' => Url::fromRequest(), - ]); + $this->addTitleTab($this->translate('New Announcement')); $form = (new AnnouncementForm(new AnnouncementIniRepository(), RepositoryMode::Insert)) ->setCsrfCounterMeasureId(Session::getSession()->getId()) @@ -90,7 +70,7 @@ public function newAction(): void }) ->handleRequest(ServerRequest::fromGlobals()); - $this->view->form = $form; + $this->addContent($form); } /** @@ -100,12 +80,7 @@ public function updateAction(): void { $this->assertPermission('application/announcements'); - $this->getTabs()->add('update-announcement', [ - 'active' => true, - 'label' => $this->translate('Update Announcement'), - 'title' => $this->translate('Update an announcement'), - 'url' => Url::fromRequest(), - ]); + $this->addTitleTab($this->translate('Update Announcement')); $form = (new AnnouncementForm( new AnnouncementIniRepository(), @@ -128,7 +103,7 @@ public function updateAction(): void $this->httpNotFound($this->translate('Announcement not found')); } - $this->view->form = $form; + $this->addContent($form); } /** @@ -138,12 +113,7 @@ public function removeAction(): void { $this->assertPermission('application/announcements'); - $this->getTabs()->add('remove-announcement', [ - 'active' => true, - 'label' => $this->translate('Remove Announcement'), - 'title' => $this->translate('Remove an announcement'), - 'url' => Url::fromRequest(), - ]); + $this->addTitleTab($this->translate('Remove Announcement')); $form = (new AnnouncementForm( new AnnouncementIniRepository(), @@ -166,7 +136,7 @@ public function removeAction(): void $this->httpNotFound($this->translate('Announcement not found')); } - $this->view->form = $form; + $this->addContent($form); } public function acknowledgeAction() diff --git a/application/views/scripts/announcements/new.phtml b/application/views/scripts/announcements/new.phtml deleted file mode 100644 index 13a8ed9ed1..0000000000 --- a/application/views/scripts/announcements/new.phtml +++ /dev/null @@ -1,6 +0,0 @@ -
- -
-
- -
diff --git a/application/views/scripts/announcements/remove.phtml b/application/views/scripts/announcements/remove.phtml deleted file mode 100644 index 13a8ed9ed1..0000000000 --- a/application/views/scripts/announcements/remove.phtml +++ /dev/null @@ -1,6 +0,0 @@ -
- -
-
- -
diff --git a/application/views/scripts/announcements/update.phtml b/application/views/scripts/announcements/update.phtml deleted file mode 100644 index 13a8ed9ed1..0000000000 --- a/application/views/scripts/announcements/update.phtml +++ /dev/null @@ -1,6 +0,0 @@ -
- -
-
- -
From 2b1cd4e9250f12bb6aa6392a0c74e461a492ad7c Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Mon, 10 Aug 2026 15:34:44 +0200 Subject: [PATCH 3/3] Add tests for `AnnouncementForm` Covers all three repository modes and the timestamp conversion `fetchEntry()` adds on top of `RepositoryForm`. The repository is backed by an in-memory `Config` whose `saveIni()` is a no-op, so inserts, updates and deletions can be asserted on the config itself without touching the filesystem. Two behaviors are pinned deliberately. Updating an announcement reassigns it to the editing user, because the disabled author element is never submitted back and the stored one is only pre-filled when the form has not been sent. This has been the case since the Zend implementation. Start and end now default to tomorrow and the day after, which the move to `localDateTime` turned from mere placeholders into actual values. --- .../Announcement/AnnouncementFormTest.php | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 test/php/application/forms/Announcement/AnnouncementFormTest.php diff --git a/test/php/application/forms/Announcement/AnnouncementFormTest.php b/test/php/application/forms/Announcement/AnnouncementFormTest.php new file mode 100644 index 0000000000..70c94e3d36 --- /dev/null +++ b/test/php/application/forms/Announcement/AnnouncementFormTest.php @@ -0,0 +1,271 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Tests\Icinga\Forms\Announcement; + +use DateTime; +use Icinga\Application\Config; +use Icinga\Authentication\Auth; +use Icinga\Data\ConfigObject; +use Icinga\Forms\Announcement\AnnouncementForm; +use Icinga\Repository\RepositoryMode; +use Icinga\Test\BaseTestCase; +use Icinga\User; +use Icinga\Web\Announcement\AnnouncementIniRepository; + +class AnnouncementFormTest extends BaseTestCase +{ + /** @var string The user the tests are authenticated as */ + public const AUTHOR = 'icingaadmin'; + + /** @var string The id of the stored announcement */ + public const ID = '6a79bd6b09595'; + + /** @var string The message of the stored announcement */ + public const MESSAGE = 'Scheduled maintenance'; + + /** @var string The stored announcement's start, in the format a browser submits */ + public const START = '2026-01-01T09:00:00'; + + /** @var string The stored announcement's end, in the format a browser submits */ + public const END = '2026-01-02T09:00:00'; + + public function setUp(): void + { + parent::setUp(); + + Auth::getInstance()->setUser(new User(static::AUTHOR)); + } + + public function testInsertModeCreatesAnnouncement(): void + { + $config = $this->createConfig(); + $form = $this->createForm($config, RepositoryMode::Insert); + $form->populate([ + 'message' => static::MESSAGE, + 'start' => static::START, + 'end' => static::END, + ]); + $form->ensureAssembled(); + $form->exposeOnSuccess(); + + $announcements = $config->toArray(); + $this->assertCount(1, $announcements); + + $announcement = $announcements[array_key_first($announcements)]; + $this->assertSame(static::MESSAGE, $announcement['message']); + $this->assertSame((new DateTime(static::START))->getTimestamp(), $announcement['start']); + $this->assertSame((new DateTime(static::END))->getTimestamp(), $announcement['end']); + } + + public function testUpdateModeChangesTheStoredAnnouncement(): void + { + $config = $this->createConfig(seed: true); + + $newMessage = 'Maintenance postponed'; + $newStart = '2026-01-03T09:00:00'; + $newEnd = '2026-01-04T09:00:00'; + + $form = $this->createForm($config, RepositoryMode::Update, static::ID); + $form->populate([ + 'message' => $newMessage, + 'start' => $newStart, + 'end' => $newEnd, + ]); + $form->ensureAssembled(); + $form->exposeOnSuccess(); + + $announcement = $config->getSection(static::ID); + + $this->assertSame($newMessage, $announcement->message); + $this->assertSame((new DateTime($newStart))->getTimestamp(), $announcement->start); + $this->assertSame((new DateTime($newEnd))->getTimestamp(), $announcement->end); + } + + public function testDeleteModeRemovesTheStoredAnnouncement(): void + { + $config = $this->createConfig(seed: true); + + $form = $this->createForm($config, RepositoryMode::Delete, static::ID); + $form->ensureAssembled(); + + $this->assertTrue($config->hasSection(static::ID)); + + $form->exposeOnSuccess(); + + $this->assertFalse($config->hasSection(static::ID)); + } + + public function testFetchEntryTurnsTheStoredTimestampsIntoDateTimes(): void + { + $entry = $this->createForm($this->createConfig(seed: true), RepositoryMode::Update, static::ID) + ->exposeFetchEntry(); + + $this->assertInstanceOf(DateTime::class, $entry->start); + $this->assertInstanceOf(DateTime::class, $entry->end); + $this->assertSame((new DateTime(static::START))->getTimestamp(), $entry->start->getTimestamp()); + $this->assertSame((new DateTime(static::END))->getTimestamp(), $entry->end->getTimestamp()); + } + + public function testFetchEntryKeepsUnsetTimestampsUnset(): void + { + $config = $this->createConfig(seed: true, overrides: ['start' => null, 'end' => null]); + + $entry = $this->createForm($config, RepositoryMode::Update, static::ID)->exposeFetchEntry(); + + $this->assertNull($entry->start); + $this->assertNull($entry->end); + } + + public function testFetchEntryReturnsFalseIfTheAnnouncementDoesNotExist(): void + { + $form = $this->createForm($this->createConfig(seed: true), RepositoryMode::Update, '00000000000000'); + + $this->assertFalse($form->exposeFetchEntry()); + } + + public function testInsertModeDefaults(): void + { + $config = $this->createConfig(); + $form = $this->createForm($config, RepositoryMode::Insert); + + $form->populate(['message' => static::MESSAGE]); + $form->ensureAssembled(); + + $this->assertSame(static::AUTHOR, $form->getElement('author')->getValue()); + $this->assertEquals(new DateTime('tomorrow'), $form->getElement('start')->getValue()); + $this->assertEquals(new DateTime('tomorrow +1day'), $form->getElement('end')->getValue()); + + $form->exposeOnSuccess(); + + $announcements = $config->toArray(); + $announcements = $announcements[array_key_first($announcements)]; + + $this->assertSame(static::AUTHOR, $announcements['author']); + $this->assertSame((new DateTime('tomorrow'))->getTimestamp(), $announcements['start']); + $this->assertSame((new DateTime('tomorrow +1day'))->getTimestamp(), $announcements['end']); + } + + public function testUpdateModePreFillsTheFormWithTheStoredAnnouncement(): void + { + $form = $this->createForm($this->createConfig(seed: true), RepositoryMode::Update, static::ID); + $form->exposeOnUpdateRequest(); + $form->ensureAssembled(); + + $this->assertSame(static::MESSAGE, $form->getElement('message')->getValue()); + $this->assertSame( + (new DateTime(static::START))->getTimestamp(), + $form->getElement('start')->getValue()->getTimestamp() + ); + $this->assertSame( + (new DateTime(static::END))->getTimestamp(), + $form->getElement('end')->getValue()->getTimestamp() + ); + } + + public function testUpdateModeReassignsTheAuthorToTheEditingUser(): void + { + $originalAuthor = 'someone_else'; + $config = $this->createConfig(seed: true, overrides: ['author' => $originalAuthor]); + + $form = $this->createForm($config, RepositoryMode::Update, static::ID); + $form->populate(['message' => static::MESSAGE]); + $form->ensureAssembled(); + + $this->assertSame($originalAuthor, $config->getSection(static::ID)->author); + + $form->exposeOnSuccess(); + + $this->assertSame(static::AUTHOR, $config->getSection(static::ID)->author); + } + + public function testTheAuthorIsOnlyEditableViaTheApi(): void + { + $form = $this->createForm($this->createConfig(), RepositoryMode::Insert); + $form->ensureAssembled(); + + $this->assertTrue($form->getElement('author')->getAttributes()->get('disabled')->getValue()); + + $_SERVER['HTTP_ACCEPT'] = 'application/json'; + + try { + $apiForm = $this->createForm($this->createConfig(), RepositoryMode::Insert); + $apiForm->ensureAssembled(); + + $this->assertFalse($apiForm->getElement('author')->getAttributes()->get('disabled')->getValue()); + } finally { + unset($_SERVER['HTTP_ACCEPT']); + } + } + + /** + * Create an announcement form backed by this test's announcements + * + * @param Config $config + * @param RepositoryMode $mode + * @param ?string $identifier + * + * @return AnnouncementForm + */ + private function createForm(Config $config, RepositoryMode $mode, ?string $identifier = null): AnnouncementForm + { + $repository = new AnnouncementIniRepository($config); + + $form = new class ($repository, $mode, $identifier) extends AnnouncementForm { + public function exposeFetchEntry(): object|false + { + return $this->fetchEntry(); + } + + public function exposeOnSuccess(): void + { + $this->onSuccess(); + } + + public function exposeOnUpdateRequest(): void + { + $this->onUpdateRequest(); + } + }; + + $form->disableCsrfCounterMeasure(); + + return $form; + } + + /** + * Create an in-memory substitute for the announcements.ini + * + * Writing back is a no-op, so that the repository's statements can be asserted + * on the config itself instead of on a file. + * + * @param bool $seed Whether to store a single announcement + * @param array $overrides Values to store instead of the defaults. + * Has no effect unless $seed is true. + * + * @return Config + */ + private function createConfig(bool $seed = false, array $overrides = []): Config + { + $config = new class (new ConfigObject()) extends Config { + public function saveIni($filePath = null, $fileMode = 0660): void + { + } + }; + + $config->getConfigObject()->setKeyColumn('id'); + + if ($seed) { + $config->setSection(static::ID, array_merge([ + 'author' => static::AUTHOR, + 'message' => static::MESSAGE, + 'start' => (new DateTime(static::START))->getTimestamp(), + 'end' => (new DateTime(static::END))->getTimestamp(), + ], $overrides)); + } + + return $config; + } +}