From 81a8120f98a5159fe963ee3a70a0fff35c7b9f1b Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 25 Apr 2026 22:55:42 +0200 Subject: [PATCH] feat(form-builder): add PublishGuard framework + 9 concrete guards (WS-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-purpose schema validation composes a PurposeGuardProvider returning a list of guards. Errors collected (not first-fail) so the builder UI surfaces every issue per save. ConditionalRequirement composes higher- order without proliferating one-off classes. RequiresIdentityKeyBinding checks the is_identity_key flag specifically; the binding-existence check is handled additively by the existing assertRequiredBindingsPresent in FormSchemaService. SchemaHasLinkedEvent checks owner_type='event' + owner_id (FormSchema uses polymorphic owner; there is no direct event_id column). i18n messages live in lang/nl/form_builder_publish_guards.php. Refs: RFC-WS-6.md §3 (Q13), §4 (V1, V3) Co-Authored-By: Claude Opus 4.7 (1M context) --- ...AppendStrategyRequiresCollectionTarget.php | 75 ++++++++++++++++ .../Publishing/ConditionalRequirement.php | 52 ++++++++++++ .../IdentityKeyBindingsOnlyInFirstSection.php | 67 +++++++++++++++ .../MaxOneIdentityKeyPerTargetEntity.php | 50 +++++++++++ .../Publishing/NoAmbiguousTrustLevels.php | 57 +++++++++++++ .../FormBuilder/Publishing/PublishGuard.php | 29 +++++++ .../Publishing/PublishGuardResult.php | 47 ++++++++++ .../Publishing/RequiresFieldType.php | 52 ++++++++++++ .../Publishing/RequiresIdentityKeyBinding.php | 49 +++++++++++ .../Publishing/SchemaHasLinkedEvent.php | 33 +++++++ .../TagCategoriesConfiguredOnAllPickers.php | 62 ++++++++++++++ api/lang/nl/form_builder_publish_guards.php | 19 +++++ ...ndStrategyRequiresCollectionTargetTest.php | 80 +++++++++++++++++ .../Publishing/ConditionalRequirementTest.php | 85 +++++++++++++++++++ ...ntityKeyBindingsOnlyInFirstSectionTest.php | 70 +++++++++++++++ .../MaxOneIdentityKeyPerTargetEntityTest.php | 70 +++++++++++++++ .../Publishing/NoAmbiguousTrustLevelsTest.php | 48 +++++++++++ .../Publishing/RequiresFieldTypeTest.php | 50 +++++++++++ .../RequiresIdentityKeyBindingTest.php | 57 +++++++++++++ .../Publishing/SchemaHasLinkedEventTest.php | 39 +++++++++ ...agCategoriesConfiguredOnAllPickersTest.php | 65 ++++++++++++++ 21 files changed, 1156 insertions(+) create mode 100644 api/app/FormBuilder/Publishing/AppendStrategyRequiresCollectionTarget.php create mode 100644 api/app/FormBuilder/Publishing/ConditionalRequirement.php create mode 100644 api/app/FormBuilder/Publishing/IdentityKeyBindingsOnlyInFirstSection.php create mode 100644 api/app/FormBuilder/Publishing/MaxOneIdentityKeyPerTargetEntity.php create mode 100644 api/app/FormBuilder/Publishing/NoAmbiguousTrustLevels.php create mode 100644 api/app/FormBuilder/Publishing/PublishGuard.php create mode 100644 api/app/FormBuilder/Publishing/PublishGuardResult.php create mode 100644 api/app/FormBuilder/Publishing/RequiresFieldType.php create mode 100644 api/app/FormBuilder/Publishing/RequiresIdentityKeyBinding.php create mode 100644 api/app/FormBuilder/Publishing/SchemaHasLinkedEvent.php create mode 100644 api/app/FormBuilder/Publishing/TagCategoriesConfiguredOnAllPickers.php create mode 100644 api/lang/nl/form_builder_publish_guards.php create mode 100644 api/tests/Unit/FormBuilder/Publishing/AppendStrategyRequiresCollectionTargetTest.php create mode 100644 api/tests/Unit/FormBuilder/Publishing/ConditionalRequirementTest.php create mode 100644 api/tests/Unit/FormBuilder/Publishing/IdentityKeyBindingsOnlyInFirstSectionTest.php create mode 100644 api/tests/Unit/FormBuilder/Publishing/MaxOneIdentityKeyPerTargetEntityTest.php create mode 100644 api/tests/Unit/FormBuilder/Publishing/NoAmbiguousTrustLevelsTest.php create mode 100644 api/tests/Unit/FormBuilder/Publishing/RequiresFieldTypeTest.php create mode 100644 api/tests/Unit/FormBuilder/Publishing/RequiresIdentityKeyBindingTest.php create mode 100644 api/tests/Unit/FormBuilder/Publishing/SchemaHasLinkedEventTest.php create mode 100644 api/tests/Unit/FormBuilder/Publishing/TagCategoriesConfiguredOnAllPickersTest.php diff --git a/api/app/FormBuilder/Publishing/AppendStrategyRequiresCollectionTarget.php b/api/app/FormBuilder/Publishing/AppendStrategyRequiresCollectionTarget.php new file mode 100644 index 00000000..99eec7f8 --- /dev/null +++ b/api/app/FormBuilder/Publishing/AppendStrategyRequiresCollectionTarget.php @@ -0,0 +1,75 @@ +fields as $field) { + /** @var FormFieldBinding $binding */ + foreach ($field->bindings as $binding) { + if ($binding->merge_strategy !== FormFieldBindingMergeStrategy::Append) { + continue; + } + + $entity = (string) $binding->target_entity; + $attribute = (string) $binding->target_attribute; + + try { + $meta = $this->registry->resolve($entity, $attribute); + } catch (UnknownBindingTargetException) { + return PublishGuardResult::failed( + guardCode: $this->code(), + messageKey: 'form_builder_publish_guards.append_strategy_requires_collection_target', + offendingFormFieldId: (string) $field->id, + context: [ + 'reason' => 'unknown_target', + 'entity' => $entity, + 'attribute' => $attribute, + ], + ); + } + + if ($meta->type !== BindingTargetType::COLLECTION) { + return PublishGuardResult::failed( + guardCode: $this->code(), + messageKey: 'form_builder_publish_guards.append_strategy_requires_collection_target', + offendingFormFieldId: (string) $field->id, + context: [ + 'reason' => 'scalar_target', + 'entity' => $entity, + 'attribute' => $attribute, + 'target_type' => $meta->type->value, + ], + ); + } + } + } + + return PublishGuardResult::passed($this->code()); + } +} diff --git a/api/app/FormBuilder/Publishing/ConditionalRequirement.php b/api/app/FormBuilder/Publishing/ConditionalRequirement.php new file mode 100644 index 00000000..5044ca80 --- /dev/null +++ b/api/app/FormBuilder/Publishing/ConditionalRequirement.php @@ -0,0 +1,52 @@ +code}"; + } + + public function evaluate(FormSchema $schema): PublishGuardResult + { + if (! ($this->predicate)($schema)) { + return PublishGuardResult::passed($this->code()); + } + + $subResult = $this->subGuard->evaluate($schema); + if ($subResult->passed) { + return PublishGuardResult::passed($this->code()); + } + + return PublishGuardResult::failed( + guardCode: $this->code(), + messageKey: $subResult->messageKey ?? 'form_builder_publish_guards.conditional', + offendingFormFieldId: $subResult->offendingFormFieldId, + context: array_merge( + $subResult->context, + ['delegated_to' => $this->subGuard->code()], + ), + ); + } +} diff --git a/api/app/FormBuilder/Publishing/IdentityKeyBindingsOnlyInFirstSection.php b/api/app/FormBuilder/Publishing/IdentityKeyBindingsOnlyInFirstSection.php new file mode 100644 index 00000000..a4411cf9 --- /dev/null +++ b/api/app/FormBuilder/Publishing/IdentityKeyBindingsOnlyInFirstSection.php @@ -0,0 +1,67 @@ +section_level_submit) { + return PublishGuardResult::passed($this->code()); + } + + $sections = $schema->sections; + if ($sections->isEmpty()) { + return PublishGuardResult::passed($this->code()); + } + + /** @var FormSchemaSection $first */ + $first = $sections->sortBy('sort_order')->first(); + $firstSectionId = (string) $first->id; + + /** @var FormField $field */ + foreach ($schema->fields as $field) { + $hasIdentityKey = false; + /** @var FormFieldBinding $binding */ + foreach ($field->bindings as $binding) { + if ((bool) $binding->is_identity_key) { + $hasIdentityKey = true; + break; + } + } + + if (! $hasIdentityKey) { + continue; + } + + if ((string) $field->form_schema_section_id !== $firstSectionId) { + return PublishGuardResult::failed( + guardCode: $this->code(), + messageKey: 'form_builder_publish_guards.identity_key_bindings_only_in_first_section', + offendingFormFieldId: (string) $field->id, + ); + } + } + + return PublishGuardResult::passed($this->code()); + } +} diff --git a/api/app/FormBuilder/Publishing/MaxOneIdentityKeyPerTargetEntity.php b/api/app/FormBuilder/Publishing/MaxOneIdentityKeyPerTargetEntity.php new file mode 100644 index 00000000..b849060b --- /dev/null +++ b/api/app/FormBuilder/Publishing/MaxOneIdentityKeyPerTargetEntity.php @@ -0,0 +1,50 @@ +fields as $field) { + /** @var FormFieldBinding $binding */ + foreach ($field->bindings as $binding) { + if (! (bool) $binding->is_identity_key) { + continue; + } + $entity = (string) $binding->target_entity; + $countsByEntity[$entity] = ($countsByEntity[$entity] ?? 0) + 1; + } + } + + foreach ($countsByEntity as $entity => $count) { + if ($count > 1) { + return PublishGuardResult::failed( + guardCode: $this->code(), + messageKey: 'form_builder_publish_guards.max_one_identity_key_per_target_entity', + context: ['entity' => $entity, 'count' => $count], + ); + } + } + + return PublishGuardResult::passed($this->code()); + } +} diff --git a/api/app/FormBuilder/Publishing/NoAmbiguousTrustLevels.php b/api/app/FormBuilder/Publishing/NoAmbiguousTrustLevels.php new file mode 100644 index 00000000..071df630 --- /dev/null +++ b/api/app/FormBuilder/Publishing/NoAmbiguousTrustLevels.php @@ -0,0 +1,57 @@ +>> $byTargetTrust */ + $byTargetTrust = []; + + /** @var FormField $field */ + foreach ($schema->fields as $field) { + /** @var FormFieldBinding $binding */ + foreach ($field->bindings as $binding) { + $key = $binding->target_entity . '.' . $binding->target_attribute; + $trust = (int) $binding->trust_level; + $byTargetTrust[$key][$trust][] = (string) $field->id; + } + } + + foreach ($byTargetTrust as $target => $trustGroups) { + foreach ($trustGroups as $trust => $fieldIds) { + if (count($fieldIds) > 1) { + return PublishGuardResult::failed( + guardCode: $this->code(), + messageKey: 'form_builder_publish_guards.no_ambiguous_trust_levels', + offendingFormFieldId: $fieldIds[0], + context: [ + 'target' => $target, + 'trust_level' => $trust, + 'form_field_ids' => $fieldIds, + ], + ); + } + } + } + + return PublishGuardResult::passed($this->code()); + } +} diff --git a/api/app/FormBuilder/Publishing/PublishGuard.php b/api/app/FormBuilder/Publishing/PublishGuard.php new file mode 100644 index 00000000..7effe4f7 --- /dev/null +++ b/api/app/FormBuilder/Publishing/PublishGuard.php @@ -0,0 +1,29 @@ + $context + */ + private function __construct( + public string $guardCode, + public bool $passed, + public ?string $messageKey = null, + public ?string $offendingFormFieldId = null, + public array $context = [], + ) {} + + public static function passed(string $guardCode): self + { + return new self(guardCode: $guardCode, passed: true); + } + + /** + * @param array $context + */ + public static function failed( + string $guardCode, + string $messageKey, + ?string $offendingFormFieldId = null, + array $context = [], + ): self { + return new self( + guardCode: $guardCode, + passed: false, + messageKey: $messageKey, + offendingFormFieldId: $offendingFormFieldId, + context: $context, + ); + } +} diff --git a/api/app/FormBuilder/Publishing/RequiresFieldType.php b/api/app/FormBuilder/Publishing/RequiresFieldType.php new file mode 100644 index 00000000..6fcd7187 --- /dev/null +++ b/api/app/FormBuilder/Publishing/RequiresFieldType.php @@ -0,0 +1,52 @@ +type->value}"; + } + + public function evaluate(FormSchema $schema): PublishGuardResult + { + $expected = $this->type->value; + $count = 0; + /** @var FormField $field */ + foreach ($schema->fields as $field) { + if ($field->field_type === $expected) { + $count++; + } + } + + if ($count >= $this->minCount) { + return PublishGuardResult::passed($this->code()); + } + + return PublishGuardResult::failed( + guardCode: $this->code(), + messageKey: 'form_builder_publish_guards.requires_field_type', + context: [ + 'type' => $this->type->value, + 'min_count' => $this->minCount, + 'actual_count' => $count, + ], + ); + } +} diff --git a/api/app/FormBuilder/Publishing/RequiresIdentityKeyBinding.php b/api/app/FormBuilder/Publishing/RequiresIdentityKeyBinding.php new file mode 100644 index 00000000..a05c95cb --- /dev/null +++ b/api/app/FormBuilder/Publishing/RequiresIdentityKeyBinding.php @@ -0,0 +1,49 @@ +entity}:{$this->attribute}"; + } + + public function evaluate(FormSchema $schema): PublishGuardResult + { + /** @var FormField $field */ + foreach ($schema->fields as $field) { + /** @var FormFieldBinding $binding */ + foreach ($field->bindings as $binding) { + if ($binding->target_entity === $this->entity + && $binding->target_attribute === $this->attribute + && (bool) $binding->is_identity_key + ) { + return PublishGuardResult::passed($this->code()); + } + } + } + + return PublishGuardResult::failed( + guardCode: $this->code(), + messageKey: 'form_builder_publish_guards.requires_identity_key_binding', + context: ['entity' => $this->entity, 'attribute' => $this->attribute], + ); + } +} diff --git a/api/app/FormBuilder/Publishing/SchemaHasLinkedEvent.php b/api/app/FormBuilder/Publishing/SchemaHasLinkedEvent.php new file mode 100644 index 00000000..b53675af --- /dev/null +++ b/api/app/FormBuilder/Publishing/SchemaHasLinkedEvent.php @@ -0,0 +1,33 @@ +owner_type === 'event' && $schema->owner_id !== null) { + return PublishGuardResult::passed($this->code()); + } + + return PublishGuardResult::failed( + guardCode: $this->code(), + messageKey: 'form_builder_publish_guards.schema_has_linked_event', + ); + } +} diff --git a/api/app/FormBuilder/Publishing/TagCategoriesConfiguredOnAllPickers.php b/api/app/FormBuilder/Publishing/TagCategoriesConfiguredOnAllPickers.php new file mode 100644 index 00000000..570d351a --- /dev/null +++ b/api/app/FormBuilder/Publishing/TagCategoriesConfiguredOnAllPickers.php @@ -0,0 +1,62 @@ +value; + /** @var FormField $field */ + foreach ($schema->fields as $field) { + if ($field->field_type !== $tagPickerValue) { + continue; + } + + $hasCategories = false; + /** @var FormFieldConfig $config */ + foreach ($field->configs as $config) { + if ($config->config_type !== FormFieldConfigType::TagCategories) { + continue; + } + $params = $config->parameters; + if ($params === []) { + continue; + } + $categories = $params['categories'] ?? null; + if (is_array($categories) && $categories !== []) { + $hasCategories = true; + break; + } + } + + if (! $hasCategories) { + return PublishGuardResult::failed( + guardCode: $this->code(), + messageKey: 'form_builder_publish_guards.tag_categories_configured_on_all_pickers', + offendingFormFieldId: (string) $field->id, + ); + } + } + + return PublishGuardResult::passed($this->code()); + } +} diff --git a/api/lang/nl/form_builder_publish_guards.php b/api/lang/nl/form_builder_publish_guards.php new file mode 100644 index 00000000..733b507b --- /dev/null +++ b/api/lang/nl/form_builder_publish_guards.php @@ -0,0 +1,19 @@ + 'Het veld voor :entity.:attribute moet als identity-key zijn aangemerkt.', + 'max_one_identity_key_per_target_entity' => 'Per doel-entiteit mag maximaal één binding identity-key zijn.', + 'requires_field_type' => 'Dit formulier moet ten minste :min_count veld(en) van type :type bevatten.', + 'schema_has_linked_event' => 'Dit formulier moet aan een evenement gekoppeld zijn.', + 'tag_categories_configured_on_all_pickers' => 'Alle TAG_PICKER-velden moeten een geconfigureerde categorie hebben.', + 'identity_key_bindings_only_in_first_section' => 'Identity-key bindings zijn alleen toegestaan in de eerste sectie wanneer per-sectie inzenden actief is.', + 'append_strategy_requires_collection_target' => 'Append-strategie is alleen toegestaan op collectie-typen.', + 'no_ambiguous_trust_levels' => 'Twee of meer bindings naar hetzelfde doel hebben hetzelfde trust-niveau — kies een uniek niveau om volgorde te bepalen.', + 'conditional' => 'Conditionele eis niet voldaan.', +]; diff --git a/api/tests/Unit/FormBuilder/Publishing/AppendStrategyRequiresCollectionTargetTest.php b/api/tests/Unit/FormBuilder/Publishing/AppendStrategyRequiresCollectionTargetTest.php new file mode 100644 index 00000000..44a17022 --- /dev/null +++ b/api/tests/Unit/FormBuilder/Publishing/AppendStrategyRequiresCollectionTargetTest.php @@ -0,0 +1,80 @@ +create(); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create([ + 'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value, + ]); + $schema->load('fields.bindings'); + + $result = $this->guard()->evaluate($schema); + $this->assertTrue($result->passed); + } + + public function test_fails_with_scalar_target_reason(): void + { + $schema = FormSchema::factory()->create(); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create([ + 'merge_strategy' => FormFieldBindingMergeStrategy::Append->value, + ]); + $schema->load('fields.bindings'); + + $result = $this->guard()->evaluate($schema); + $this->assertFalse($result->passed); + $this->assertSame('scalar_target', $result->context['reason']); + } + + public function test_passes_with_collection_target(): void + { + $schema = FormSchema::factory()->create(); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'dietary_preferences')->create([ + 'merge_strategy' => FormFieldBindingMergeStrategy::Append->value, + ]); + $schema->load('fields.bindings'); + + $result = $this->guard()->evaluate($schema); + $this->assertTrue($result->passed); + } + + public function test_fails_with_unknown_target_reason(): void + { + $schema = FormSchema::factory()->create(); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'unknown_attr')->create([ + 'merge_strategy' => FormFieldBindingMergeStrategy::Append->value, + ]); + $schema->load('fields.bindings'); + + $result = $this->guard()->evaluate($schema); + $this->assertFalse($result->passed); + $this->assertSame('unknown_target', $result->context['reason']); + } + + private function guard(): AppendStrategyRequiresCollectionTarget + { + return new AppendStrategyRequiresCollectionTarget( + $this->app->make(BindingTypeRegistry::class), + ); + } +} diff --git a/api/tests/Unit/FormBuilder/Publishing/ConditionalRequirementTest.php b/api/tests/Unit/FormBuilder/Publishing/ConditionalRequirementTest.php new file mode 100644 index 00000000..5ad5bc1b --- /dev/null +++ b/api/tests/Unit/FormBuilder/Publishing/ConditionalRequirementTest.php @@ -0,0 +1,85 @@ +create(); + + $guard = new ConditionalRequirement( + predicate: fn (): bool => false, + subGuard: $this->alwaysFails(), + code: 'test_predicate_false', + ); + + $result = $guard->evaluate($schema); + $this->assertTrue($result->passed); + $this->assertSame('conditional:test_predicate_false', $result->guardCode); + } + + public function test_passes_when_predicate_true_and_sub_passes(): void + { + $schema = FormSchema::factory()->create(); + + $guard = new ConditionalRequirement( + predicate: fn (): bool => true, + subGuard: $this->alwaysPasses(), + code: 'test_sub_passes', + ); + + $result = $guard->evaluate($schema); + $this->assertTrue($result->passed); + } + + public function test_fails_when_predicate_true_and_sub_fails(): void + { + $schema = FormSchema::factory()->create(); + + $guard = new ConditionalRequirement( + predicate: fn (): bool => true, + subGuard: $this->alwaysFails(), + code: 'test_sub_fails', + ); + + $result = $guard->evaluate($schema); + $this->assertFalse($result->passed); + $this->assertSame('conditional:test_sub_fails', $result->guardCode); + $this->assertSame('inner_failure', $result->messageKey); + $this->assertSame('inner_guard', $result->context['delegated_to']); + } + + private function alwaysPasses(): PublishGuard + { + return new class implements PublishGuard { + public function code(): string { return 'inner_guard'; } + public function evaluate(FormSchema $schema): PublishGuardResult + { + return PublishGuardResult::passed($this->code()); + } + }; + } + + private function alwaysFails(): PublishGuard + { + return new class implements PublishGuard { + public function code(): string { return 'inner_guard'; } + public function evaluate(FormSchema $schema): PublishGuardResult + { + return PublishGuardResult::failed($this->code(), 'inner_failure'); + } + }; + } +} diff --git a/api/tests/Unit/FormBuilder/Publishing/IdentityKeyBindingsOnlyInFirstSectionTest.php b/api/tests/Unit/FormBuilder/Publishing/IdentityKeyBindingsOnlyInFirstSectionTest.php new file mode 100644 index 00000000..f39a948a --- /dev/null +++ b/api/tests/Unit/FormBuilder/Publishing/IdentityKeyBindingsOnlyInFirstSectionTest.php @@ -0,0 +1,70 @@ +create(['section_level_submit' => false]); + FormSchemaSection::factory()->create(['form_schema_id' => $schema->id, 'sort_order' => 0]); + $section2 = FormSchemaSection::factory()->create(['form_schema_id' => $schema->id, 'sort_order' => 1]); + $field = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'form_schema_section_id' => $section2->id, // identity key in 2nd section + ]); + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email') + ->create(['is_identity_key' => true]); + $schema->load(['fields.bindings', 'sections']); + + $result = (new IdentityKeyBindingsOnlyInFirstSection())->evaluate($schema); + $this->assertTrue($result->passed); + } + + public function test_passes_when_identity_key_in_first_section(): void + { + $schema = FormSchema::factory()->create(['section_level_submit' => true]); + $section1 = FormSchemaSection::factory()->create(['form_schema_id' => $schema->id, 'sort_order' => 0]); + FormSchemaSection::factory()->create(['form_schema_id' => $schema->id, 'sort_order' => 1]); + $field = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'form_schema_section_id' => $section1->id, + ]); + FormFieldBinding::factory()->forField($field)->entityOwned('artist', 'email') + ->create(['is_identity_key' => true]); + $schema->load(['fields.bindings', 'sections']); + + $result = (new IdentityKeyBindingsOnlyInFirstSection())->evaluate($schema); + $this->assertTrue($result->passed); + } + + public function test_fails_when_identity_key_in_later_section(): void + { + $schema = FormSchema::factory()->create(['section_level_submit' => true]); + FormSchemaSection::factory()->create(['form_schema_id' => $schema->id, 'sort_order' => 0]); + $section2 = FormSchemaSection::factory()->create(['form_schema_id' => $schema->id, 'sort_order' => 1]); + $field = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'form_schema_section_id' => $section2->id, + ]); + FormFieldBinding::factory()->forField($field)->entityOwned('artist', 'email') + ->create(['is_identity_key' => true]); + $schema->load(['fields.bindings', 'sections']); + + $result = (new IdentityKeyBindingsOnlyInFirstSection())->evaluate($schema); + $this->assertFalse($result->passed); + $this->assertSame((string) $field->id, $result->offendingFormFieldId); + } +} diff --git a/api/tests/Unit/FormBuilder/Publishing/MaxOneIdentityKeyPerTargetEntityTest.php b/api/tests/Unit/FormBuilder/Publishing/MaxOneIdentityKeyPerTargetEntityTest.php new file mode 100644 index 00000000..c7062b92 --- /dev/null +++ b/api/tests/Unit/FormBuilder/Publishing/MaxOneIdentityKeyPerTargetEntityTest.php @@ -0,0 +1,70 @@ +create(); + FormField::factory()->create(['form_schema_id' => $schema->id]); + $schema->load('fields.bindings'); + + $result = (new MaxOneIdentityKeyPerTargetEntity())->evaluate($schema); + $this->assertTrue($result->passed); + } + + public function test_passes_with_one_identity_key_per_entity(): void + { + $schema = FormSchema::factory()->create(); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email') + ->create(['is_identity_key' => true]); + $schema->load('fields.bindings'); + + $result = (new MaxOneIdentityKeyPerTargetEntity())->evaluate($schema); + $this->assertTrue($result->passed); + } + + public function test_fails_with_two_identity_keys_same_entity(): void + { + $schema = FormSchema::factory()->create(); + $f1 = FormField::factory()->create(['form_schema_id' => $schema->id]); + $f2 = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($f1)->entityOwned('person', 'email') + ->create(['is_identity_key' => true]); + FormFieldBinding::factory()->forField($f2)->entityOwned('person', 'first_name') + ->create(['is_identity_key' => true]); + $schema->load('fields.bindings'); + + $result = (new MaxOneIdentityKeyPerTargetEntity())->evaluate($schema); + $this->assertFalse($result->passed); + $this->assertSame('person', $result->context['entity']); + } + + public function test_passes_with_one_identity_key_each_on_different_entities(): void + { + $schema = FormSchema::factory()->create(); + $f1 = FormField::factory()->create(['form_schema_id' => $schema->id]); + $f2 = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($f1)->entityOwned('person', 'email') + ->create(['is_identity_key' => true]); + FormFieldBinding::factory()->forField($f2)->entityOwned('company', 'email') + ->create(['is_identity_key' => true]); + $schema->load('fields.bindings'); + + $result = (new MaxOneIdentityKeyPerTargetEntity())->evaluate($schema); + $this->assertTrue($result->passed); + } +} diff --git a/api/tests/Unit/FormBuilder/Publishing/NoAmbiguousTrustLevelsTest.php b/api/tests/Unit/FormBuilder/Publishing/NoAmbiguousTrustLevelsTest.php new file mode 100644 index 00000000..d6b3f02b --- /dev/null +++ b/api/tests/Unit/FormBuilder/Publishing/NoAmbiguousTrustLevelsTest.php @@ -0,0 +1,48 @@ +create(); + $f1 = FormField::factory()->create(['form_schema_id' => $schema->id]); + $f2 = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($f1)->entityOwned('person', 'email') + ->create(['trust_level' => 80]); + FormFieldBinding::factory()->forField($f2)->entityOwned('person', 'email') + ->create(['trust_level' => 60]); + $schema->load('fields.bindings'); + + $result = (new NoAmbiguousTrustLevels())->evaluate($schema); + $this->assertTrue($result->passed); + } + + public function test_fails_with_duplicate_trust_levels(): void + { + $schema = FormSchema::factory()->create(); + $f1 = FormField::factory()->create(['form_schema_id' => $schema->id]); + $f2 = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($f1)->entityOwned('person', 'email') + ->create(['trust_level' => 50]); + FormFieldBinding::factory()->forField($f2)->entityOwned('person', 'email') + ->create(['trust_level' => 50]); + $schema->load('fields.bindings'); + + $result = (new NoAmbiguousTrustLevels())->evaluate($schema); + $this->assertFalse($result->passed); + $this->assertSame('person.email', $result->context['target']); + } +} diff --git a/api/tests/Unit/FormBuilder/Publishing/RequiresFieldTypeTest.php b/api/tests/Unit/FormBuilder/Publishing/RequiresFieldTypeTest.php new file mode 100644 index 00000000..d9844011 --- /dev/null +++ b/api/tests/Unit/FormBuilder/Publishing/RequiresFieldTypeTest.php @@ -0,0 +1,50 @@ +create(); + FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::EMAIL->value, + ]); + $schema->load('fields'); + + $result = (new RequiresFieldType(FormFieldType::EMAIL, 1))->evaluate($schema); + $this->assertTrue($result->passed); + } + + public function test_fails_when_no_fields_of_type(): void + { + $schema = FormSchema::factory()->create(); + FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TEXT->value, + ]); + $schema->load('fields'); + + $result = (new RequiresFieldType(FormFieldType::EMAIL, 1))->evaluate($schema); + $this->assertFalse($result->passed); + $this->assertSame(0, $result->context['actual_count']); + } + + public function test_code_includes_type_value(): void + { + $guard = new RequiresFieldType(FormFieldType::EMAIL); + $this->assertSame('requires_field_type:EMAIL', $guard->code()); + } +} diff --git a/api/tests/Unit/FormBuilder/Publishing/RequiresIdentityKeyBindingTest.php b/api/tests/Unit/FormBuilder/Publishing/RequiresIdentityKeyBindingTest.php new file mode 100644 index 00000000..07e3167e --- /dev/null +++ b/api/tests/Unit/FormBuilder/Publishing/RequiresIdentityKeyBindingTest.php @@ -0,0 +1,57 @@ +assertSame('requires_identity_key_binding:person:email', $guard->code()); + } + + public function test_passes_when_identity_key_binding_exists(): void + { + $schema = FormSchema::factory()->create(); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory() + ->forField($field) + ->entityOwned('person', 'email') + ->create(['is_identity_key' => true]); + + $schema->load('fields.bindings'); + + $result = (new RequiresIdentityKeyBinding('person', 'email'))->evaluate($schema); + $this->assertTrue($result->passed); + } + + public function test_fails_when_binding_present_without_identity_key_flag(): void + { + $schema = FormSchema::factory()->create(); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory() + ->forField($field) + ->entityOwned('person', 'email') + ->create(['is_identity_key' => false]); + + $schema->load('fields.bindings'); + + $result = (new RequiresIdentityKeyBinding('person', 'email'))->evaluate($schema); + $this->assertFalse($result->passed); + $this->assertSame( + 'form_builder_publish_guards.requires_identity_key_binding', + $result->messageKey, + ); + } +} diff --git a/api/tests/Unit/FormBuilder/Publishing/SchemaHasLinkedEventTest.php b/api/tests/Unit/FormBuilder/Publishing/SchemaHasLinkedEventTest.php new file mode 100644 index 00000000..74fc4bbb --- /dev/null +++ b/api/tests/Unit/FormBuilder/Publishing/SchemaHasLinkedEventTest.php @@ -0,0 +1,39 @@ +create(); + $schema = FormSchema::factory()->create([ + 'owner_type' => 'event', + 'owner_id' => $event->id, + ]); + + $result = (new SchemaHasLinkedEvent())->evaluate($schema); + $this->assertTrue($result->passed); + } + + public function test_fails_when_owner_type_is_not_event(): void + { + $schema = FormSchema::factory()->create([ + 'owner_type' => 'organisation', + 'owner_id' => null, + ]); + + $result = (new SchemaHasLinkedEvent())->evaluate($schema); + $this->assertFalse($result->passed); + } +} diff --git a/api/tests/Unit/FormBuilder/Publishing/TagCategoriesConfiguredOnAllPickersTest.php b/api/tests/Unit/FormBuilder/Publishing/TagCategoriesConfiguredOnAllPickersTest.php new file mode 100644 index 00000000..c4a069a1 --- /dev/null +++ b/api/tests/Unit/FormBuilder/Publishing/TagCategoriesConfiguredOnAllPickersTest.php @@ -0,0 +1,65 @@ +create(); + FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TEXT->value, + ]); + $schema->load('fields.configs'); + + $result = (new TagCategoriesConfiguredOnAllPickers())->evaluate($schema); + $this->assertTrue($result->passed); + } + + public function test_passes_when_tag_picker_has_categories_config(): void + { + $schema = FormSchema::factory()->create(); + $field = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TAG_PICKER->value, + ]); + FormFieldConfig::query()->withoutGlobalScopes()->create([ + 'owner_type' => 'form_field', + 'owner_id' => $field->id, + 'config_type' => FormFieldConfigType::TagCategories->value, + 'parameters' => ['categories' => ['Veiligheid', 'Horeca']], + ]); + $schema->load('fields.configs'); + + $result = (new TagCategoriesConfiguredOnAllPickers())->evaluate($schema); + $this->assertTrue($result->passed); + } + + public function test_fails_when_tag_picker_has_no_categories(): void + { + $schema = FormSchema::factory()->create(); + $field = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TAG_PICKER->value, + ]); + $schema->load('fields.configs'); + + $result = (new TagCategoriesConfiguredOnAllPickers())->evaluate($schema); + $this->assertFalse($result->passed); + $this->assertSame((string) $field->id, $result->offendingFormFieldId); + } +}