diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 5061e8f2c..7517f6107 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -202,6 +202,11 @@ public function register(IRegistrationContext $context): void { // it wires everything else by hand (WOO-559). Without it the SPA // catch-all answered the store page's JSON call with HTML 200. (new StorePlaneRegistrar())->register($context); + + // OpenRegister asks which published forms write into a schema that + // is about to change (decision 179). Portaliq answers for its form + // bindings. + (new FormDependentsRegistrar())->register($context); }//end register() /** diff --git a/lib/AppInfo/FormDependentsRegistrar.php b/lib/AppInfo/FormDependentsRegistrar.php new file mode 100644 index 000000000..5c3cc420a --- /dev/null +++ b/lib/AppInfo/FormDependentsRegistrar.php @@ -0,0 +1,55 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007 + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\AppInfo; + +use OCA\OpenRegister\Event\FormDestinationDependentsEvent; +use OCA\Portaliq\Listener\FormDestinationDependentsListener; +use OCP\AppFramework\Bootstrap\IRegistrationContext; + +/** + * Registers the listener that announces portaliq's published form bindings. + * + * @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007 + */ +class FormDependentsRegistrar { + /** + * Register the listener on OpenRegister's event. + * + * The event is named by its class constant, which needs no autoload: on + * an instance without OpenRegister nothing dispatches it and the listener + * is never built. + * + * @param IRegistrationContext $context The registration context. + * + * @return void + * + * @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007 + */ + public function register(IRegistrationContext $context): void { + $context->registerEventListener(FormDestinationDependentsEvent::class, FormDestinationDependentsListener::class); + }//end register() +}//end class diff --git a/lib/Listener/FormDestinationDependentsListener.php b/lib/Listener/FormDestinationDependentsListener.php new file mode 100644 index 000000000..ed5b49088 --- /dev/null +++ b/lib/Listener/FormDestinationDependentsListener.php @@ -0,0 +1,250 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007 + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Listener; + +use OCA\OpenRegister\Event\FormDestinationDependentsEvent; +use OCA\OpenRegister\Service\Form\FormDependent; +use OCA\Portaliq\AppInfo\Application; +use OCA\Portaliq\Service\Intake\DestinationMapping; +use OCA\Portaliq\Service\Intake\PortalFormBindingResolver; +use OCA\Portaliq\Service\PortalObjectWriter; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; + +/** + * Announces each published form binding that writes into the schema about to change. + * + * @template-implements IEventListener + * + * @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007 + */ +class FormDestinationDependentsListener implements IEventListener { + private const REGISTER = 'portaliq'; + + private const BINDINGS = 'portalFormBinding'; + + /** + * Constructor. + * + * @param PortalFormBindingResolver $bindings Reads the published bindings and what each renders. + * @param DestinationMapping $mapping Builds the mapping a submit of a binding sends. + * @param PortalObjectWriter $writer Sets a binding back to draft. + * @param LoggerInterface $logger The logger. + */ + public function __construct( + private readonly PortalFormBindingResolver $bindings, + private readonly DestinationMapping $mapping, + private readonly PortalObjectWriter $writer, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Add one form per published binding whose destination is the event's schema. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @listener-placement inline answer — the asker reads the event right after dispatch; a deferred answer comes after the save is judged. + * @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007 + */ + public function handle(Event $event): void { + if (($event instanceof FormDestinationDependentsEvent) === false) { + return; + } + + try { + $schema = $event->getSchema(); + $names = $this->namesOf(schema: $schema); + $properties = ['properties' => $schema->getProperties()]; + foreach ($this->bindings->allPublishedBindings() as $binding) { + $form = $this->formFor(binding: $binding, names: $names, schema: $properties); + if ($form !== null) { + $event->addForm($form); + } + } + } catch (Throwable $e) { + // A schema save must not fail on portaliq's answer. The price is + // that this save is judged without portaliq's forms, so say so loudly. + $this->logger->error( + 'Portaliq: could not list the forms that write into a schema about to change', + ['reason' => $e->getMessage()] + ); + } + }//end handle() + + /** + * The form a binding stands for, or null when it does not write into this schema. + * + * A binding whose form lives on another host, and one that renders no + * form, write nothing and are left out. + * + * @param array $binding The published binding. + * @param array $names The names the schema answers to. + * @param array $schema The proposed schema, with `properties`. + * + * @return FormDependent|null + */ + private function formFor(array $binding, array $names, array $schema): ?FormDependent { + $destination = $this->mapping->destinationOf(binding: $binding); + $bindingId = $this->idOf(row: $binding); + if ($destination === null || $bindingId === '' || in_array($destination['schema'], $names, true) === false) { + return null; + } + + $render = $this->bindings->render(binding: $binding); + if ((string)($render['kind'] ?? '') === PortalFormBindingResolver::KIND_EXTERNAL || ($render['resolvesToNoForm'] ?? false) === true) { + return null; + } + + $portal = (string)($binding['portal'] ?? ''); + + return new FormDependent( + id: $bindingId, + app: Application::APP_ID, + title: $this->titleOf(binding: $binding, render: $render), + author: $this->authorOf(binding: $binding), + mapping: $this->mapping->mappingFor( + binding: $binding, + fields: (array)($render['fields'] ?? []), + schema: $schema, + subjectRef: DestinationMapping::SUBJECT_PLACEHOLDER + ), + unpublish: function () use ($bindingId, $portal): void { + $this->unpublish(bindingId: $bindingId, portal: $portal); + }, + // Every portal submit reaches OpenRegister as an anonymous write + // (DestinationSubmit), so the form is judged as a public one. + audience: 'public', + ); + }//end formFor() + + /** + * The name an author knows the form by: the form's own, else the binding's, else its portal and route. + * + * @param array $binding The binding. + * @param array $render What it renders to. + * + * @return string + */ + private function titleOf(array $binding, array $render): string { + $candidates = [ + (string)($render['formName'] ?? ''), + (string)($binding['formName'] ?? ''), + (string)($binding['portal'] ?? '') . ' ' . (string)($binding['route'] ?? ''), + ]; + foreach ($candidates as $candidate) { + if (trim($candidate) !== '') { + return trim($candidate); + } + } + + return ''; + }//end titleOf() + + /** + * The user to tell when the form is unpublished: the binding's owner, when it has one. + * + * @param array $binding The binding. + * + * @return string|null + */ + private function authorOf(array $binding): ?string { + $owner = ($binding['@self']['owner'] ?? null); + if (is_string($owner) === true && $owner !== '') { + return $owner; + } + + return null; + }//end authorOf() + + /** + * Set a binding back to draft: it stops rendering, and keeps everything else. + * + * @param string $bindingId The binding. + * @param string $portal The portal it belongs to. + * + * @return void + * + * @throws RuntimeException When the binding could not be written, so OpenRegister reports the failure. + */ + private function unpublish(string $bindingId, string $portal): void { + $written = $this->writer->updateObject( + register: self::REGISTER, + schema: self::BINDINGS, + scopeField: 'portal', + subjectRef: $portal, + organisation: '', + id: $bindingId, + data: ['status' => 'draft'] + ); + if ($written === null) { + throw new RuntimeException('The form binding ' . $bindingId . ' could not be set to draft.'); + } + }//end unpublish() + + /** + * The names a binding may use for this schema: its slug, uuid and id. + * + * @param object $schema The proposed schema. + * + * @return array + */ + private function namesOf(object $schema): array { + $names = []; + foreach ([$schema->getSlug(), $schema->getUuid(), $schema->getId()] as $name) { + if ($name !== null && (string)$name !== '') { + $names[] = (string)$name; + } + } + + return $names; + }//end namesOf() + + /** + * @param array $row A stored row. + * + * @return string Its identifier, or ''. + */ + private function idOf(array $row): string { + $self = (array)($row['@self'] ?? []); + foreach ([($row['id'] ?? null), ($row['uuid'] ?? null), ($self['id'] ?? null), ($self['uuid'] ?? null)] as $candidate) { + if ((is_string($candidate) === true || is_int($candidate) === true) && (string)$candidate !== '') { + return (string)$candidate; + } + } + + return ''; + }//end idOf() +}//end class diff --git a/lib/Service/Intake/PortalFormBindingResolver.php b/lib/Service/Intake/PortalFormBindingResolver.php index ac61fe586..8b048bda9 100644 --- a/lib/Service/Intake/PortalFormBindingResolver.php +++ b/lib/Service/Intake/PortalFormBindingResolver.php @@ -222,6 +222,35 @@ public function publishedBindings(string $portal): array { return $published; }//end publishedBindings() + /** + * Every published binding, of every portal. + * + * For the one caller that asks about the whole instance: a destination + * schema is about to change and its forms must be found, whichever + * portal shows them. Never reached from a visitor's request. + * + * @return array> + * + * @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007 + */ + public function allPublishedBindings(): array { + $rows = $this->reader->readCollection( + register: self::REGISTER, + schema: self::SCHEMA, + scopeField: 'status', + subjectRef: 'published', + organisation: '', + limit: 500 + ); + + return array_values( + array_filter( + $rows, + static fn ($row): bool => is_array($row) === true && (string)($row['status'] ?? 'draft') === 'published' + ) + ); + }//end allPublishedBindings() + /** * Whether a case type is inside the scope a portal declared. * diff --git a/openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md b/openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md index d46091ef0..68c6e1dfe 100644 --- a/openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md +++ b/openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md @@ -7,7 +7,7 @@ ### Requirement: A form binding names its destination and is valid against it (REQ-PIFO-007) -A `portalFormBinding` SHALL store `destination { register, schema }`, and MAY name `subjectField` (the property that holds the signed-in resident's subject reference), `paymentField` (the property the payment state is read from), `fields` (a form field written into a differently named property) and `fixed` (values every submit writes). Saving or publishing a binding SHALL run OpenRegister's form destination validator over the bound form. A binding with findings SHALL NOT be published, from the first release (decision 181); a binding in `draft` MAY be saved with its findings, which the preview shows (decision 189). +A `portalFormBinding` SHALL store `destination { register, schema }`, and MAY name `subjectField` (the property that holds the signed-in resident's subject reference), `paymentField` (the property the payment state is read from), `fields` (a form field written into a differently named property) and `fixed` (values every submit writes). Saving or publishing a binding SHALL run OpenRegister's form destination validator over the bound form. A binding with findings SHALL NOT be published, from the first release (decision 181); a binding in `draft` MAY be saved with its findings, which the preview shows (decision 189). When OpenRegister asks which published forms write into a schema that is about to change (`FormDestinationDependentsEvent`), portaliq SHALL announce every published binding, of every portal, whose destination is that schema and that renders a form here, and SHALL set a binding back to `draft` when OpenRegister has it unpublished (decision 181). #### Scenario: A draft binding saves with its findings - **GIVEN** a binding in status `draft` whose form misses a required property of its destination @@ -19,6 +19,12 @@ A `portalFormBinding` SHALL store `destination { register, schema }`, and MAY na - **WHEN** an administrator publishes the binding - **THEN** the publish is refused and the preview names `communicationChannel` as `required-unmapped` +#### Scenario: The destination schema changes under a published binding +- **GIVEN** a published binding whose form writes `toelichting` into its destination schema +- **WHEN** OpenRegister is about to save that schema without `toelichting` and asks which published forms write into it +- **THEN** portaliq announces the binding as a form of app `portaliq`, with the mapping a submit of it sends and audience `public` +- **AND** when OpenRegister has the form unpublished, the binding goes back to `draft` and keeps everything else + ### Requirement: A saved form is a draft of its destination object (REQ-PIFO-008) "Save and carry on later" SHALL save the destination object with OpenRegister metadata status `draft` (decision 180). The draft MAY miss required answers. It SHALL NOT hold a type-invalid answer; such an answer SHALL be refused on its field. Sending SHALL move the object out of `draft` through OpenRegister's submit service, with full validation; only then SHALL it get its reference and received moment. portaliq SHALL keep no draft store of its own. The draft's owner SHALL be `portal:` (decision 195): only that signed-in resident reads, saves, sends or discards it. A draft SHALL read as gone, and be discarded, once the action's retention has passed since its last change (`updatedAt`): `draft.retentionDays`, 30 days by default (decision 201). Picked files SHALL be uploaded into the draft object. The site SHALL label a saved draft "Voorlopig" (decision 200). diff --git a/openspec/changes/submit-creates-the-case-directly/tasks.md b/openspec/changes/submit-creates-the-case-directly/tasks.md index d628cc999..50df4499f 100644 --- a/openspec/changes/submit-creates-the-case-directly/tasks.md +++ b/openspec/changes/submit-creates-the-case-directly/tasks.md @@ -61,6 +61,12 @@ - [x] 6.3 `intake-conditional-questions-and-drafts`: its draft requirements move to draft destination objects (decision 180); open PR #1152 follows the same rule - [x] 6.4 `embedded-intake-form` REQ-EIF-003/004, `intake-pay-on-submit` REQ-IPS-003/005, `form-statements-intro-and-confirmation-mail` REQ-FCI-003, `resident-identity-in-forms` REQ-RIF-002/005, `portal-shared-runtime` task 69 +## 6b. Answer OpenRegister when a destination schema changes + +- [x] 6b.1 `lib/Listener/FormDestinationDependentsListener.php` adds one `FormDependent` per published `portalFormBinding` whose destination is the event's schema (slug, uuid or id), with the mapping `DestinationMapping::mappingFor()` builds against the proposed schema, audience `public`, and an unpublish that sets the binding to `draft`. An external form and a binding that renders no form are left out. Registered by `lib/AppInfo/FormDependentsRegistrar.php`, called from `Application::register()`. `PortalFormBindingResolver::allPublishedBindings()` reads every portal's published bindings. + - PHPUnit `FormDestinationDependentsListenerTest` (OpenRegister's real event, schema, validator and `FormDependentsCheck`), `FormDependentsRegistrarTest::testTheAppRegistersTheListener`, `PortalFormBindingResolverTest::testEveryPortalsPublishedBindingsAreListedAndNoDraft` +- [ ] 6b.2 (live pass, decision 139) On an instance with dossiq: publish a binding into a case schema, remove a property its form writes, save the schema. The save answers with the binding under `affected`, the binding reads `draft`, and its owner gets the notification + ## 7. Verification - [ ] 7.1 `composer check:strict`, `npm run lint`, `openspec validate submit-creates-the-case-directly --strict` diff --git a/psalm.xml b/psalm.xml index d0db8802a..6d96293d4 100644 --- a/psalm.xml +++ b/psalm.xml @@ -89,6 +89,8 @@ + + diff --git a/tests/Unit/AppInfo/FormDependentsRegistrarTest.php b/tests/Unit/AppInfo/FormDependentsRegistrarTest.php new file mode 100644 index 000000000..eaf93ab52 --- /dev/null +++ b/tests/Unit/AppInfo/FormDependentsRegistrarTest.php @@ -0,0 +1,119 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007 + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Tests\Unit\AppInfo; + +use OCA\Portaliq\AppInfo\Application; +use OCA\Portaliq\AppInfo\FormDependentsRegistrar; +use OCA\Portaliq\Listener\FormDestinationDependentsListener; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\EventDispatcher\IEventListener; +use PHPUnit\Framework\TestCase; +use ReflectionClass; +use Throwable; + +/** + * A listener with a full test suite and no registration answers nothing: the + * schema save would be judged without portaliq's forms and nobody would see + * it. So the wiring is asserted from the caller, the app's own `register()`. + * + * No coverage metadata on purpose: the second test runs the whole of + * `Application::register()`, which touches every registrar the app has. + */ +final class FormDependentsRegistrarTest extends TestCase { + /** + * The event OpenRegister dispatches, as `FormDependentsCheck::assess()` names it. + */ + private const EVENT = 'OCA\\OpenRegister\\Event\\FormDestinationDependentsEvent'; + + /** + * A registration context that writes down every event listener it is given. + * + * @param array $recorded Filled with event and listener. + * + * @return IRegistrationContext + */ + private function recordingContext(array &$recorded): IRegistrationContext { + $context = $this->createMock(IRegistrationContext::class); + $context->method('registerEventListener') + ->willReturnCallback( + static function (string $event, string $listener, int $priority = 0) use (&$recorded): void { + $recorded[] = [$event, $listener]; + } + ); + + return $context; + }//end recordingContext() + + /** + * The registrar puts the listener on exactly the event OpenRegister dispatches. + * + * @return void + */ + public function testTheRegistrarPutsTheListenerOnOpenRegistersEvent(): void { + $recorded = []; + + (new FormDependentsRegistrar())->register($this->recordingContext($recorded)); + + $this->assertSame([[self::EVENT, FormDestinationDependentsListener::class]], $recorded); + $this->assertTrue(is_subclass_of(FormDestinationDependentsListener::class, IEventListener::class)); + }//end testTheRegistrarPutsTheListenerOnOpenRegistersEvent() + + /** + * The event name is a class OpenRegister really has, and really dispatches. + * + * A listener on a name nothing emits never runs. This reads OpenRegister's + * own source, so it needs an OpenRegister checkout beside the test run. + * + * @return void + */ + public function testOpenRegisterDispatchesThatEvent(): void { + if (class_exists(self::EVENT) === false) { + $this->markTestSkipped('OpenRegister with FormDestinationDependentsEvent is not loadable here (set PORTALIQ_OPENREGISTER_LIB to a current openregister lib/).'); + } + + $event = new ReflectionClass(self::EVENT); + $source = (string)file_get_contents(dirname((string)$event->getFileName()) . '/../Service/Form/FormDependentsCheck.php'); + + $this->assertStringContainsString('new FormDestinationDependentsEvent(', $source); + $this->assertStringContainsString('dispatchTyped($event)', $source); + $this->assertTrue($event->hasMethod('addForm')); + $this->assertTrue($event->hasMethod('getSchema')); + }//end testOpenRegisterDispatchesThatEvent() + + /** + * The app's own `register()` reaches the registrar: the listener is among what the app registers. + * + * The app is built without its constructor, which needs a running + * Nextcloud; `register()` itself only talks to the context it is handed. + * + * @return void + */ + public function testTheAppRegistersTheListener(): void { + $recorded = []; + $app = (new ReflectionClass(Application::class))->newInstanceWithoutConstructor(); + + try { + $app->register($this->recordingContext($recorded)); + } catch (Throwable $e) { + $this->fail('Application::register() did not run to its end: ' . $e->getMessage()); + } + + $this->assertContains( + [self::EVENT, FormDestinationDependentsListener::class], + $recorded, + 'The app registers no listener for FormDestinationDependentsEvent, so OpenRegister judges a schema change without portaliq\'s forms.' + ); + }//end testTheAppRegistersTheListener() +}//end class diff --git a/tests/Unit/Listener/FormDestinationDependentsListenerTest.php b/tests/Unit/Listener/FormDestinationDependentsListenerTest.php new file mode 100644 index 000000000..35321d729 --- /dev/null +++ b/tests/Unit/Listener/FormDestinationDependentsListenerTest.php @@ -0,0 +1,297 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/submit-creates-the-case-directly/specs/portal-intake-form/spec.md#requirement-a-form-binding-names-its-destination-and-is-valid-against-it-req-pifo-007 + */ + +declare(strict_types=1); + +namespace OCA\Portaliq\Tests\Unit\Listener; + +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Event\FormDestinationDependentsEvent; +use OCA\OpenRegister\Service\Form\FormDependentsCheck; +use OCA\OpenRegister\Service\Form\FormDestinationValidator; +use OCA\OpenRegister\Service\Form\FormFieldRules; +use OCA\Portaliq\Listener\FormDestinationDependentsListener; +use OCA\Portaliq\Service\Intake\DestinationMapping; +use OCA\Portaliq\Service\Intake\PortalFormBindingResolver; +use OCA\Portaliq\Service\PortalObjectWriter; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use OCP\IL10N; +use OCP\Notification\IManager; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * The event, the schema, the dependent and the check that asks are + * OpenRegister's real classes: a faked event would let a wrong accessor pass. + */ +#[CoversClass(FormDestinationDependentsListener::class)] +#[UsesClass(DestinationMapping::class)] +final class FormDestinationDependentsListenerTest extends TestCase { + /** + * The updates the listener asked the writer for. + * + * @var array> + */ + private array $written = []; + + /** + * Whether the writer refuses. + * + * @var bool + */ + private bool $writeFails = false; + + /** + * Skip where OpenRegister's event cannot be loaded. + * + * @return void + */ + protected function setUp(): void { + if (class_exists(FormDestinationDependentsEvent::class) === false || class_exists(FormDependentsCheck::class) === false) { + $this->markTestSkipped('OpenRegister with FormDestinationDependentsEvent is not loadable here (set PORTALIQ_OPENREGISTER_LIB to a current openregister lib/).'); + } + + $this->written = []; + $this->writeFails = false; + }//end setUp() + + /** + * The schema `case` as it is about to be saved. + * + * @param array $properties Its properties. + * + * @return Schema + */ + private function proposed(array $properties): Schema { + $schema = new Schema(); + $schema->setSlug('case'); + $schema->setUuid('11111111-2222-4333-8444-555555555555'); + $schema->setProperties($properties); + + return $schema; + }//end proposed() + + /** + * A published binding that writes into `case`. + * + * @param array $extra Keys to replace. + * + * @return array + */ + private static function binding(array $extra = []): array { + return $extra + [ + 'id' => 'b-1', + '@self' => ['owner' => 'anouk'], + 'portal' => 'zuiddrecht', + 'route' => '/melding', + 'status' => 'published', + 'destination' => ['register' => 'dossiq', 'schema' => 'case', 'fixed' => ['channel' => 'portal']], + ]; + }//end binding() + + /** + * The listener over the given published bindings. + * + * @param array> $bindings The published bindings of every portal. + * + * @return FormDestinationDependentsListener + */ + private function listener(array $bindings): FormDestinationDependentsListener { + $resolver = $this->createMock(PortalFormBindingResolver::class); + $resolver->method('allPublishedBindings')->willReturn($bindings); + $resolver->method('render')->willReturnCallback( + static function (array $binding): array { + if (($binding['intakeKind'] ?? 'hosted') === 'external') { + return ['kind' => PortalFormBindingResolver::KIND_EXTERNAL, 'resolvesToNoForm' => false]; + } + + if (($binding['route'] ?? '') === '/leeg') { + return ['kind' => PortalFormBindingResolver::KIND_HOSTED, 'resolvesToNoForm' => true]; + } + + return [ + 'kind' => PortalFormBindingResolver::KIND_HOSTED, + 'resolvesToNoForm' => false, + 'formName' => 'Melding openbare ruimte', + 'fields' => [ + ['name' => 'onderwerp', 'label' => 'Onderwerp', 'type' => 'text', 'required' => true], + ['name' => 'toelichting', 'label' => 'Toelichting', 'type' => 'textarea'], + ], + ]; + } + ); + + $writer = $this->createMock(PortalObjectWriter::class); + $writer->method('updateObject')->willReturnCallback( + function (string $register, string $schema, string $scopeField, string $subjectRef, string $organisation, string $id, $data): ?array { + $this->written[] = ['register' => $register, 'schema' => $schema, 'scopeField' => $scopeField, 'subjectRef' => $subjectRef, 'id' => $id, 'data' => $data]; + + return $this->writeFails ? null : (['id' => $id] + (array)$data); + } + ); + + return new FormDestinationDependentsListener($resolver, new DestinationMapping(), $writer, $this->createMock(LoggerInterface::class)); + }//end listener() + + /** + * OpenRegister's own check, whose dispatcher hands the event to the listener. + * + * @param FormDestinationDependentsListener $listener The listener. + * + * @return FormDependentsCheck + */ + private function check(FormDestinationDependentsListener $listener): FormDependentsCheck { + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback( + static function (Event $event) use ($listener): void { + $listener->handle($event); + } + ); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback(static fn (string $text, array $args = []): string => vsprintf($text, $args)); + + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturnArgument(2); + + return new FormDependentsCheck($dispatcher, new FormDestinationValidator($l10n, new FormFieldRules()), $config, $this->createMock(IManager::class), $this->createMock(LoggerInterface::class)); + }//end check() + + /** + * One form per published binding of this schema, with what a submit of it sends. + * + * @return void + */ + public function testEachPublishedBindingOfTheSchemaIsAnnounced(): void { + $event = new FormDestinationDependentsEvent($this->proposed(['onderwerp' => ['type' => 'string'], 'toelichting' => ['type' => 'string'], 'channel' => ['type' => 'string']])); + + $this->listener([ + self::binding(), + self::binding(['id' => 'b-2', 'portal' => 'noord', 'destination' => ['register' => 'dossiq', 'schema' => '11111111-2222-4333-8444-555555555555']]), + self::binding(['id' => 'b-3', 'destination' => ['register' => 'dossiq', 'schema' => 'ticket']]), + self::binding(['id' => 'b-4', 'intakeKind' => 'external']), + self::binding(['id' => 'b-5', 'route' => '/leeg']), + self::binding(['id' => '', '@self' => []]), + self::binding(['id' => 'b-7', 'destination' => []]), + ])->handle($event); + + $forms = $event->getForms(); + $this->assertSame(['b-1', 'b-2'], array_map(static fn ($form): string => $form->id, $forms), 'another schema, an external form, a binding without a form, without an id and without a destination are left out'); + + $form = $forms[0]; + $this->assertSame('portaliq', $form->app); + $this->assertSame('Melding openbare ruimte', $form->title); + $this->assertSame('anouk', $form->author); + $this->assertSame('public', $form->audience); + $this->assertSame(['onderwerp', 'toelichting'], array_column($form->mapping['fields'], 'property')); + $this->assertSame(['channel' => 'portal'], $form->mapping['fixed']); + $this->assertIsCallable($form->unpublish); + }//end testEachPublishedBindingOfTheSchemaIsAnnounced() + + /** + * A legacy binding names its destination with caseRegister and caseSchema, and has no owner. + * + * @return void + */ + public function testABindingFromBeforeTheUpgradeIsFoundToo(): void { + $event = new FormDestinationDependentsEvent($this->proposed(['onderwerp' => ['type' => 'string']])); + $binding = ['uuid' => 'b-9', 'portal' => 'zuiddrecht', 'route' => '/oud', 'status' => 'published', 'caseRegister' => 'dossiq', 'caseSchema' => 'case']; + + $this->listener([$binding])->handle($event); + + $this->assertCount(1, $event->getForms()); + $this->assertSame('b-9', $event->getForms()[0]->id); + $this->assertNull($event->getForms()[0]->author); + }//end testABindingFromBeforeTheUpgradeIsFoundToo() + + /** + * Asked by OpenRegister's own check: a schema that still takes the form's answers affects nothing. + * + * @return void + */ + public function testASchemaChangeThatKeepsTheFormWholeAffectsNothing(): void { + $check = $this->check($this->listener([self::binding()])); + + $assessment = $check->assess($this->proposed(['onderwerp' => ['type' => 'string'], 'toelichting' => ['type' => 'string'], 'channel' => ['type' => 'string']])); + + $this->assertSame([], $assessment['affected']); + $this->assertFalse($assessment['refused']); + }//end testASchemaChangeThatKeepsTheFormWholeAffectsNothing() + + /** + * Asked by OpenRegister's own check: dropping a property the form writes + * names the binding, and applying the save sets that binding to draft. + * + * @return void + */ + public function testABreakingSchemaChangeUnpublishesTheBinding(): void { + $check = $this->check($this->listener([self::binding()])); + + $assessment = $check->assess($this->proposed(['onderwerp' => ['type' => 'string'], 'channel' => ['type' => 'string']])); + + $this->assertSame(['b-1'], array_column($assessment['affected'], 'id')); + $this->assertSame('portaliq', $assessment['affected'][0]['app']); + $this->assertContains(FormDestinationValidator::PROPERTY_UNKNOWN, array_column($assessment['affected'][0]['findings'], 'code')); + $this->assertSame([], $this->written, 'nothing is written while the save is only being judged'); + + $listed = $check->apply($assessment); + + $this->assertSame('unpublished', $listed[0]['outcome']); + $this->assertSame( + [['register' => 'portaliq', 'schema' => 'portalFormBinding', 'scopeField' => 'portal', 'subjectRef' => 'zuiddrecht', 'id' => 'b-1', 'data' => ['status' => 'draft']]], + $this->written + ); + }//end testABreakingSchemaChangeUnpublishesTheBinding() + + /** + * A binding that could not be set to draft is reported as such, not as unpublished. + * + * @return void + */ + public function testAFailedUnpublishIsReported(): void { + $this->writeFails = true; + $check = $this->check($this->listener([self::binding()])); + + $listed = $check->apply($check->assess($this->proposed(['onderwerp' => ['type' => 'string'], 'channel' => ['type' => 'string']]))); + + $this->assertSame('unpublish-failed', $listed[0]['outcome']); + + $event = new FormDestinationDependentsEvent($this->proposed(['onderwerp' => ['type' => 'string']])); + $this->listener([self::binding()])->handle($event); + $this->expectException(RuntimeException::class); + ($event->getForms()[0]->unpublish)(); + }//end testAFailedUnpublishIsReported() + + /** + * Another event is not looked at, and a read that fails does not break the schema save. + * + * @return void + */ + public function testAnotherEventAndAFailingReadLeaveTheSaveAlone(): void { + $resolver = $this->createMock(PortalFormBindingResolver::class); + $resolver->expects($this->once())->method('allPublishedBindings')->willThrowException(new RuntimeException('register not installed')); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('error'); + $listener = new FormDestinationDependentsListener($resolver, new DestinationMapping(), $this->createMock(PortalObjectWriter::class), $logger); + + $listener->handle(new Event()); + + $event = new FormDestinationDependentsEvent($this->proposed([])); + $listener->handle($event); + $this->assertSame([], $event->getForms()); + }//end testAnotherEventAndAFailingReadLeaveTheSaveAlone() +}//end class diff --git a/tests/Unit/Service/Intake/PortalFormBindingResolverTest.php b/tests/Unit/Service/Intake/PortalFormBindingResolverTest.php index 4f5c8870e..1ce769995 100644 --- a/tests/Unit/Service/Intake/PortalFormBindingResolverTest.php +++ b/tests/Unit/Service/Intake/PortalFormBindingResolverTest.php @@ -359,6 +359,23 @@ public function testAPortalDeclaresOnlyTheCaseTypesItPublishedABindingFor(): voi }//end testAPortalDeclaresOnlyTheCaseTypesItPublishedABindingFor() + /** + * The whole instance's published bindings, for a destination schema that + * is about to change: every portal's, and no draft. + * + * @return void + */ + public function testEveryPortalsPublishedBindingsAreListedAndNoDraft(): void { + $this->seedRow('portalFormBinding', $this->binding()); + $this->seedRow('portalFormBinding', $this->binding(['portal' => 'gemeente-y', 'route' => 'aanvragen/kap'])); + $this->seedRow('portalFormBinding', $this->binding(['route' => 'aanvragen/concept', 'status' => 'draft'])); + + $all = $this->resolver()->allPublishedBindings(); + + $this->assertSame(['gemeente-x', 'gemeente-y'], array_column($all, 'portal')); + $this->assertSame(['published', 'published'], array_column($all, 'status')); + }//end testEveryPortalsPublishedBindingsAreListedAndNoDraft() + /** * gate-7. The predicate the anonymous route refuses on: another portal's * case type, a draft one, and a partly-right triple are all outside.