From e3c9211e3fbafc411d31dca23a2cd30d26c4691a Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 25 Apr 2026 23:01:19 +0200 Subject: [PATCH] feat(form-builder): wire PurposeGuardProvider per purpose (WS-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PurposeGuardProvider as a parallel interface to PurposeDefinition (value object stays untouched). Seven concrete providers, one per v1.0 purpose, each declaring its publish-guard list. Registry resolves and caches providers via guards_class config key. Universal guards (MaxOneIdentityKeyPerTargetEntity, AppendStrategyRequiresCollectionTarget, NoAmbiguousTrustLevels, IdentityKeyBindingsOnlyInFirstSection) wire into every purpose. The section guard is a cheap no-op when section_level_submit=false. ArtistAdvanceGuards omits RequiresIdentityKeyBinding because the artist subject is resolved via portal token, not form data. Same reasoning for supplier_intake (production_request) and the auth-based purposes. Includes a cross-cutting BindingTypeRegistryConsistencyTest that verifies tasks 5/7/8 do not contradict each other (registry ↔ guards ↔ purpose required_bindings). Refs: RFC-WS-6.md §3 (Q9, Q13) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Purposes/Guards/ArtistAdvanceGuards.php | 30 ++++++ .../Guards/EventRegistrationGuards.php | 66 ++++++++++++ .../Purposes/Guards/IncidentReportGuards.php | 28 +++++ .../Guards/PostEventEvaluationGuards.php | 28 +++++ .../Guards/SignatureContractGuards.php | 28 +++++ .../Purposes/Guards/SupplierIntakeGuards.php | 28 +++++ .../Purposes/Guards/UserProfileGuards.php | 28 +++++ .../Purposes/PurposeGuardProvider.php | 21 ++++ .../FormBuilder/Purposes/PurposeRegistry.php | 37 +++++++ api/config/form_builder/purposes.php | 7 ++ .../BindingTypeRegistryConsistencyTest.php | 94 ++++++++++++++++ .../Purposes/AllPurposesGuardWiringTest.php | 41 +++++++ .../ArtistAdvanceGuardsIntegrationTest.php | 69 ++++++++++++ ...EventRegistrationGuardsIntegrationTest.php | 102 ++++++++++++++++++ .../SupplierIntakeGuardsIntegrationTest.php | 66 ++++++++++++ .../Purposes/PurposeGuardProvidersTest.php | 90 ++++++++++++++++ 16 files changed, 763 insertions(+) create mode 100644 api/app/FormBuilder/Purposes/Guards/ArtistAdvanceGuards.php create mode 100644 api/app/FormBuilder/Purposes/Guards/EventRegistrationGuards.php create mode 100644 api/app/FormBuilder/Purposes/Guards/IncidentReportGuards.php create mode 100644 api/app/FormBuilder/Purposes/Guards/PostEventEvaluationGuards.php create mode 100644 api/app/FormBuilder/Purposes/Guards/SignatureContractGuards.php create mode 100644 api/app/FormBuilder/Purposes/Guards/SupplierIntakeGuards.php create mode 100644 api/app/FormBuilder/Purposes/Guards/UserProfileGuards.php create mode 100644 api/app/FormBuilder/Purposes/PurposeGuardProvider.php create mode 100644 api/tests/Feature/FormBuilder/Bindings/BindingTypeRegistryConsistencyTest.php create mode 100644 api/tests/Feature/FormBuilder/Purposes/AllPurposesGuardWiringTest.php create mode 100644 api/tests/Feature/FormBuilder/Purposes/ArtistAdvanceGuardsIntegrationTest.php create mode 100644 api/tests/Feature/FormBuilder/Purposes/EventRegistrationGuardsIntegrationTest.php create mode 100644 api/tests/Feature/FormBuilder/Purposes/SupplierIntakeGuardsIntegrationTest.php create mode 100644 api/tests/Unit/FormBuilder/Purposes/PurposeGuardProvidersTest.php diff --git a/api/app/FormBuilder/Purposes/Guards/ArtistAdvanceGuards.php b/api/app/FormBuilder/Purposes/Guards/ArtistAdvanceGuards.php new file mode 100644 index 00000000..4b95f8fc --- /dev/null +++ b/api/app/FormBuilder/Purposes/Guards/ArtistAdvanceGuards.php @@ -0,0 +1,30 @@ +registry), + new NoAmbiguousTrustLevels(), + // No RequiresIdentityKeyBinding — artist resolved via portal token. + ]; + } +} diff --git a/api/app/FormBuilder/Purposes/Guards/EventRegistrationGuards.php b/api/app/FormBuilder/Purposes/Guards/EventRegistrationGuards.php new file mode 100644 index 00000000..83179be7 --- /dev/null +++ b/api/app/FormBuilder/Purposes/Guards/EventRegistrationGuards.php @@ -0,0 +1,66 @@ +fieldTypePresent(FormFieldType::AVAILABILITY_PICKER), + subGuard: new SchemaHasLinkedEvent(), + code: 'availability_picker_requires_event', + ), + new ConditionalRequirement( + predicate: $this->fieldTypePresent(FormFieldType::TAG_PICKER), + subGuard: new TagCategoriesConfiguredOnAllPickers(), + code: 'tag_picker_requires_tag_categories', + ), + new AppendStrategyRequiresCollectionTarget($this->registry), + new NoAmbiguousTrustLevels(), + new IdentityKeyBindingsOnlyInFirstSection(), + ]; + } + + /** + * @return \Closure(FormSchema): bool + */ + private function fieldTypePresent(FormFieldType $type): \Closure + { + $value = $type->value; + + return static function (FormSchema $schema) use ($value): bool { + /** @var FormField $field */ + foreach ($schema->fields as $field) { + if ($field->field_type === $value) { + return true; + } + } + + return false; + }; + } +} diff --git a/api/app/FormBuilder/Purposes/Guards/IncidentReportGuards.php b/api/app/FormBuilder/Purposes/Guards/IncidentReportGuards.php new file mode 100644 index 00000000..1d372062 --- /dev/null +++ b/api/app/FormBuilder/Purposes/Guards/IncidentReportGuards.php @@ -0,0 +1,28 @@ +registry), + new NoAmbiguousTrustLevels(), + new IdentityKeyBindingsOnlyInFirstSection(), + // Anonymous-allowed — no identity key required. + ]; + } +} diff --git a/api/app/FormBuilder/Purposes/Guards/PostEventEvaluationGuards.php b/api/app/FormBuilder/Purposes/Guards/PostEventEvaluationGuards.php new file mode 100644 index 00000000..bfb5bc48 --- /dev/null +++ b/api/app/FormBuilder/Purposes/Guards/PostEventEvaluationGuards.php @@ -0,0 +1,28 @@ +registry), + new NoAmbiguousTrustLevels(), + new IdentityKeyBindingsOnlyInFirstSection(), + // Person resolved via auth. + ]; + } +} diff --git a/api/app/FormBuilder/Purposes/Guards/SignatureContractGuards.php b/api/app/FormBuilder/Purposes/Guards/SignatureContractGuards.php new file mode 100644 index 00000000..4e7519b5 --- /dev/null +++ b/api/app/FormBuilder/Purposes/Guards/SignatureContractGuards.php @@ -0,0 +1,28 @@ +registry), + new NoAmbiguousTrustLevels(), + new IdentityKeyBindingsOnlyInFirstSection(), + // User resolved via auth. + ]; + } +} diff --git a/api/app/FormBuilder/Purposes/Guards/SupplierIntakeGuards.php b/api/app/FormBuilder/Purposes/Guards/SupplierIntakeGuards.php new file mode 100644 index 00000000..47972fce --- /dev/null +++ b/api/app/FormBuilder/Purposes/Guards/SupplierIntakeGuards.php @@ -0,0 +1,28 @@ +registry), + new NoAmbiguousTrustLevels(), + new IdentityKeyBindingsOnlyInFirstSection(), + // No RequiresIdentityKeyBinding — company resolved via production_request. + ]; + } +} diff --git a/api/app/FormBuilder/Purposes/Guards/UserProfileGuards.php b/api/app/FormBuilder/Purposes/Guards/UserProfileGuards.php new file mode 100644 index 00000000..9d85c37a --- /dev/null +++ b/api/app/FormBuilder/Purposes/Guards/UserProfileGuards.php @@ -0,0 +1,28 @@ +registry), + new NoAmbiguousTrustLevels(), + new IdentityKeyBindingsOnlyInFirstSection(), + // User resolved via auth. + ]; + } +} diff --git a/api/app/FormBuilder/Purposes/PurposeGuardProvider.php b/api/app/FormBuilder/Purposes/PurposeGuardProvider.php new file mode 100644 index 00000000..8f98133e --- /dev/null +++ b/api/app/FormBuilder/Purposes/PurposeGuardProvider.php @@ -0,0 +1,21 @@ + */ + public function publishGuards(): array; +} diff --git a/api/app/FormBuilder/Purposes/PurposeRegistry.php b/api/app/FormBuilder/Purposes/PurposeRegistry.php index e8d98baf..7de34fac 100644 --- a/api/app/FormBuilder/Purposes/PurposeRegistry.php +++ b/api/app/FormBuilder/Purposes/PurposeRegistry.php @@ -13,6 +13,12 @@ final class PurposeRegistry /** @var array|null */ private ?array $cache = null; + /** @var array>|null */ + private ?array $guardClassCache = null; + + /** @var array */ + private array $guardProviderCache = []; + public function __construct(private readonly ConfigRepository $config) {} /** @return array keyed by slug */ @@ -26,6 +32,7 @@ final class PurposeRegistry $raw = (array) $this->config->get('form_builder.purposes', []); $definitions = []; + $guardClasses = []; foreach ($raw as $slug => $attrs) { $mode = $attrs['default_submission_mode'] ?? null; if (! $mode instanceof FormSubmissionMode) { @@ -34,6 +41,13 @@ final class PurposeRegistry ); } + $guardsClass = $attrs['guards_class'] ?? null; + if (! is_string($guardsClass) || ! is_subclass_of($guardsClass, PurposeGuardProvider::class)) { + throw new \InvalidArgumentException( + "Purpose '{$slug}' has invalid guards_class; expected class-string implementing PurposeGuardProvider." + ); + } + $definitions[(string) $slug] = new PurposeDefinition( slug: (string) $slug, label: (string) ($attrs['label'] ?? ''), @@ -42,11 +56,34 @@ final class PurposeRegistry allowsPublicAccess: (bool) ($attrs['allows_public_access'] ?? false), requiredBindings: array_values((array) ($attrs['required_bindings'] ?? [])), ); + $guardClasses[(string) $slug] = $guardsClass; } + $this->guardClassCache = $guardClasses; + return $this->cache = $definitions; } + public function guardProviderFor(string $slug): PurposeGuardProvider + { + if (! $this->has($slug)) { + throw Exceptions\PurposeNotFoundException::forSlug($slug); + } + + if (isset($this->guardProviderCache[$slug])) { + return $this->guardProviderCache[$slug]; + } + + /** @var array> $classes */ + $classes = $this->guardClassCache ?? []; + $class = $classes[$slug]; + + /** @var PurposeGuardProvider $instance */ + $instance = resolve($class); + + return $this->guardProviderCache[$slug] = $instance; + } + public function get(string $slug): PurposeDefinition { $all = $this->all(); diff --git a/api/config/form_builder/purposes.php b/api/config/form_builder/purposes.php index 7a6f023b..8f9e76ee 100644 --- a/api/config/form_builder/purposes.php +++ b/api/config/form_builder/purposes.php @@ -32,6 +32,7 @@ return [ 'default_submission_mode' => FormSubmissionMode::SINGLE, 'allows_public_access' => true, 'required_bindings' => ['person.email', 'person.first_name', 'person.last_name'], + 'guards_class' => \App\FormBuilder\Purposes\Guards\EventRegistrationGuards::class, ], 'artist_advance' => [ @@ -40,6 +41,7 @@ return [ 'default_submission_mode' => FormSubmissionMode::DRAFT_SINGLE, 'allows_public_access' => false, 'required_bindings' => [], + 'guards_class' => \App\FormBuilder\Purposes\Guards\ArtistAdvanceGuards::class, ], 'supplier_intake' => [ @@ -48,6 +50,7 @@ return [ 'default_submission_mode' => FormSubmissionMode::SINGLE, 'allows_public_access' => false, 'required_bindings' => ['company.name'], + 'guards_class' => \App\FormBuilder\Purposes\Guards\SupplierIntakeGuards::class, ], 'post_event_evaluation' => [ @@ -56,6 +59,7 @@ return [ 'default_submission_mode' => FormSubmissionMode::SINGLE, 'allows_public_access' => false, 'required_bindings' => [], + 'guards_class' => \App\FormBuilder\Purposes\Guards\PostEventEvaluationGuards::class, ], 'incident_report' => [ @@ -64,6 +68,7 @@ return [ 'default_submission_mode' => FormSubmissionMode::MULTIPLE, 'allows_public_access' => false, 'required_bindings' => [], + 'guards_class' => \App\FormBuilder\Purposes\Guards\IncidentReportGuards::class, ], 'signature_contract' => [ @@ -72,6 +77,7 @@ return [ 'default_submission_mode' => FormSubmissionMode::SINGLE, 'allows_public_access' => false, 'required_bindings' => [], + 'guards_class' => \App\FormBuilder\Purposes\Guards\SignatureContractGuards::class, ], 'user_profile' => [ @@ -80,6 +86,7 @@ return [ 'default_submission_mode' => FormSubmissionMode::SINGLE, 'allows_public_access' => false, 'required_bindings' => [], + 'guards_class' => \App\FormBuilder\Purposes\Guards\UserProfileGuards::class, ], ]; diff --git a/api/tests/Feature/FormBuilder/Bindings/BindingTypeRegistryConsistencyTest.php b/api/tests/Feature/FormBuilder/Bindings/BindingTypeRegistryConsistencyTest.php new file mode 100644 index 00000000..ac4b3362 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/BindingTypeRegistryConsistencyTest.php @@ -0,0 +1,94 @@ +app->make(BindingTypeRegistry::class); + + foreach ($registry->entities() as $entity) { + foreach ($registry->attributesFor($entity) as $attribute) { + $meta = $registry->resolve($entity, $attribute); + $this->assertContains( + $meta->php, + self::KNOWN_PHP_TYPES, + "Unknown PHP type '{$meta->php}' for {$entity}.{$attribute}", + ); + } + } + } + + public function test_registry_target_types_are_valid_enum_cases(): void + { + $registry = $this->app->make(BindingTypeRegistry::class); + $cases = array_map(static fn (BindingTargetType $c) => $c->value, BindingTargetType::cases()); + + foreach ($registry->entities() as $entity) { + foreach ($registry->attributesFor($entity) as $attribute) { + $meta = $registry->resolve($entity, $attribute); + $this->assertContains($meta->type->value, $cases); + } + } + } + + public function test_identity_key_guards_only_target_eligible_attributes(): void + { + $purposes = $this->app->make(PurposeRegistry::class); + $registry = $this->app->make(BindingTypeRegistry::class); + + foreach ($purposes->all() as $slug => $_def) { + $provider = $purposes->guardProviderFor($slug); + foreach ($provider->publishGuards() as $guard) { + if (! $guard instanceof RequiresIdentityKeyBinding) { + continue; + } + $code = $guard->code(); // 'requires_identity_key_binding:{entity}:{attribute}' + [, $entity, $attribute] = explode(':', $code); + + $this->assertTrue( + $registry->isKnown($entity, $attribute), + "Purpose '{$slug}' requires identity-key on unknown target {$entity}.{$attribute}", + ); + $this->assertTrue( + $registry->isIdentityKeyEligible($entity, $attribute), + "Purpose '{$slug}' requires identity-key on ineligible target {$entity}.{$attribute}", + ); + } + } + } + + public function test_purpose_required_bindings_target_known_registry_attributes(): void + { + $purposes = $this->app->make(PurposeRegistry::class); + $registry = $this->app->make(BindingTypeRegistry::class); + + foreach ($purposes->all() as $slug => $definition) { + foreach ($definition->requiredBindings as $path) { + $parts = explode('.', $path); + $this->assertCount(2, $parts, "Bad required_binding path '{$path}' for purpose '{$slug}'"); + [$entity, $attribute] = $parts; + + $this->assertTrue( + $registry->isKnown($entity, $attribute), + "Purpose '{$slug}' requires binding to unknown target {$entity}.{$attribute}", + ); + } + } + } +} diff --git a/api/tests/Feature/FormBuilder/Purposes/AllPurposesGuardWiringTest.php b/api/tests/Feature/FormBuilder/Purposes/AllPurposesGuardWiringTest.php new file mode 100644 index 00000000..c4c12fc6 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Purposes/AllPurposesGuardWiringTest.php @@ -0,0 +1,41 @@ + $config */ + $config = config('form_builder.purposes'); + $this->assertNotEmpty($config); + + foreach ($config as $slug => $attrs) { + $this->assertArrayHasKey( + 'guards_class', + $attrs, + "Purpose '{$slug}' is missing the guards_class config key.", + ); + } + } + + public function test_registry_resolves_provider_for_every_purpose(): void + { + $registry = $this->app->make(PurposeRegistry::class); + + foreach (array_keys($registry->all()) as $slug) { + $provider = $registry->guardProviderFor($slug); + $this->assertInstanceOf(PurposeGuardProvider::class, $provider); + $this->assertNotEmpty( + $provider->publishGuards(), + "Provider for '{$slug}' returned no guards.", + ); + } + } +} diff --git a/api/tests/Feature/FormBuilder/Purposes/ArtistAdvanceGuardsIntegrationTest.php b/api/tests/Feature/FormBuilder/Purposes/ArtistAdvanceGuardsIntegrationTest.php new file mode 100644 index 00000000..18421090 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Purposes/ArtistAdvanceGuardsIntegrationTest.php @@ -0,0 +1,69 @@ +buildValidSchema(); + $provider = $this->app->make(ArtistAdvanceGuards::class); + + foreach ($provider->publishGuards() as $guard) { + $result = $guard->evaluate($schema); + $this->assertTrue( + $result->passed, + "Guard {$guard->code()} failed: {$result->messageKey}", + ); + } + } + + public function test_append_on_scalar_target_fails(): void + { + $schema = $this->buildValidSchema(); + FormFieldBinding::query() + ->withoutGlobalScopes() + ->whereIn('owner_id', $schema->fields->pluck('id')) + ->where('target_attribute', 'stage_name') + ->update(['merge_strategy' => FormFieldBindingMergeStrategy::Append->value]); + $schema->load('fields.bindings'); + + $provider = $this->app->make(ArtistAdvanceGuards::class); + + $failedCodes = []; + foreach ($provider->publishGuards() as $guard) { + $result = $guard->evaluate($schema); + if (! $result->passed) { + $failedCodes[] = $guard->code(); + } + } + + $this->assertContains('append_strategy_requires_collection_target', $failedCodes); + } + + private function buildValidSchema(): FormSchema + { + $schema = FormSchema::factory()->create([ + 'purpose' => FormPurpose::ARTIST_ADVANCE->value, + ]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($field)->entityOwned('artist', 'stage_name') + ->create(['trust_level' => 70]); + $schema->load(['fields.bindings', 'sections']); + + return $schema; + } +} diff --git a/api/tests/Feature/FormBuilder/Purposes/EventRegistrationGuardsIntegrationTest.php b/api/tests/Feature/FormBuilder/Purposes/EventRegistrationGuardsIntegrationTest.php new file mode 100644 index 00000000..7e19f5f0 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Purposes/EventRegistrationGuardsIntegrationTest.php @@ -0,0 +1,102 @@ +buildValidSchema(); + $provider = $this->app->make(EventRegistrationGuards::class); + + foreach ($provider->publishGuards() as $guard) { + $result = $guard->evaluate($schema); + $this->assertTrue( + $result->passed, + "Guard {$guard->code()} unexpectedly failed: {$result->messageKey}", + ); + } + } + + public function test_missing_identity_key_flag_fails_specific_guard(): void + { + $schema = $this->buildValidSchema(); + // Mutation: clear is_identity_key on the email binding. + FormFieldBinding::query() + ->withoutGlobalScopes() + ->whereIn('owner_id', $schema->fields->pluck('id')) + ->where('target_attribute', 'email') + ->update(['is_identity_key' => false]); + $schema->load('fields.bindings'); + + $provider = $this->app->make(EventRegistrationGuards::class); + + $failedCodes = []; + foreach ($provider->publishGuards() as $guard) { + $result = $guard->evaluate($schema); + if (! $result->passed) { + $failedCodes[] = $guard->code(); + } + } + + $this->assertContains( + 'requires_identity_key_binding:person:email', + $failedCodes, + 'Expected the identity-key flag-check guard to fail.', + ); + } + + public function test_registry_resolves_event_registration_to_this_provider(): void + { + $registry = $this->app->make(PurposeRegistry::class); + $provider = $registry->guardProviderFor('event_registration'); + $this->assertInstanceOf(EventRegistrationGuards::class, $provider); + } + + private function buildValidSchema(): FormSchema + { + $schema = FormSchema::factory()->create([ + 'purpose' => FormPurpose::EVENT_REGISTRATION->value, + 'section_level_submit' => false, + ]); + + $emailField = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::EMAIL->value, + ]); + FormFieldBinding::factory()->forField($emailField)->entityOwned('person', 'email') + ->create(['is_identity_key' => true, 'trust_level' => 80]); + + $firstNameField = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TEXT->value, + ]); + FormFieldBinding::factory()->forField($firstNameField)->entityOwned('person', 'first_name') + ->create(['is_identity_key' => false, 'trust_level' => 60]); + + $lastNameField = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TEXT->value, + ]); + FormFieldBinding::factory()->forField($lastNameField)->entityOwned('person', 'last_name') + ->create(['is_identity_key' => false, 'trust_level' => 50]); + + $schema->load(['fields.bindings', 'fields.configs', 'sections']); + + return $schema; + } +} diff --git a/api/tests/Feature/FormBuilder/Purposes/SupplierIntakeGuardsIntegrationTest.php b/api/tests/Feature/FormBuilder/Purposes/SupplierIntakeGuardsIntegrationTest.php new file mode 100644 index 00000000..5d6a362f --- /dev/null +++ b/api/tests/Feature/FormBuilder/Purposes/SupplierIntakeGuardsIntegrationTest.php @@ -0,0 +1,66 @@ +buildValidSchema(); + $provider = $this->app->make(SupplierIntakeGuards::class); + + foreach ($provider->publishGuards() as $guard) { + $result = $guard->evaluate($schema); + $this->assertTrue( + $result->passed, + "Guard {$guard->code()} failed: {$result->messageKey}", + ); + } + } + + public function test_two_identity_keys_on_company_fails(): void + { + $schema = $this->buildValidSchema(); + $extraField = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($extraField)->entityOwned('company', 'kvk_number') + ->create(['is_identity_key' => true, 'trust_level' => 60]); + $schema->load('fields.bindings'); + + $provider = $this->app->make(SupplierIntakeGuards::class); + + $failedCodes = []; + foreach ($provider->publishGuards() as $guard) { + $result = $guard->evaluate($schema); + if (! $result->passed) { + $failedCodes[] = $guard->code(); + } + } + + $this->assertContains('max_one_identity_key_per_target_entity', $failedCodes); + } + + private function buildValidSchema(): FormSchema + { + $schema = FormSchema::factory()->create([ + 'purpose' => FormPurpose::SUPPLIER_INTAKE->value, + ]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($field)->entityOwned('company', 'name') + ->create(['is_identity_key' => true, 'trust_level' => 80]); + $schema->load(['fields.bindings', 'sections']); + + return $schema; + } +} diff --git a/api/tests/Unit/FormBuilder/Purposes/PurposeGuardProvidersTest.php b/api/tests/Unit/FormBuilder/Purposes/PurposeGuardProvidersTest.php new file mode 100644 index 00000000..65f522d5 --- /dev/null +++ b/api/tests/Unit/FormBuilder/Purposes/PurposeGuardProvidersTest.php @@ -0,0 +1,90 @@ +codesFor(EventRegistrationGuards::class); + $this->assertContains('requires_identity_key_binding:person:email', $codes); + $this->assertContains('max_one_identity_key_per_target_entity', $codes); + $this->assertContains('requires_field_type:EMAIL', $codes); + $this->assertContains('conditional:availability_picker_requires_event', $codes); + $this->assertContains('conditional:tag_picker_requires_tag_categories', $codes); + $this->assertContains('append_strategy_requires_collection_target', $codes); + $this->assertContains('no_ambiguous_trust_levels', $codes); + $this->assertContains('identity_key_bindings_only_in_first_section', $codes); + } + + public function test_artist_advance_guards_omit_identity_key_requirement(): void + { + $codes = $this->codesFor(ArtistAdvanceGuards::class); + $this->assertNotContains('requires_identity_key_binding:person:email', $codes); + $this->assertContains('max_one_identity_key_per_target_entity', $codes); + $this->assertContains('identity_key_bindings_only_in_first_section', $codes); + } + + public function test_supplier_intake_guards_universal_only(): void + { + $codes = $this->codesFor(SupplierIntakeGuards::class); + $this->assertContains('max_one_identity_key_per_target_entity', $codes); + $this->assertContains('append_strategy_requires_collection_target', $codes); + $this->assertContains('no_ambiguous_trust_levels', $codes); + $this->assertContains('identity_key_bindings_only_in_first_section', $codes); + } + + public function test_post_event_evaluation_guards_universal_only(): void + { + $codes = $this->codesFor(PostEventEvaluationGuards::class); + $this->assertContains('max_one_identity_key_per_target_entity', $codes); + $this->assertContains('append_strategy_requires_collection_target', $codes); + $this->assertContains('no_ambiguous_trust_levels', $codes); + $this->assertContains('identity_key_bindings_only_in_first_section', $codes); + } + + public function test_incident_report_guards_universal_only(): void + { + $codes = $this->codesFor(IncidentReportGuards::class); + $this->assertContains('max_one_identity_key_per_target_entity', $codes); + $this->assertContains('append_strategy_requires_collection_target', $codes); + } + + public function test_signature_contract_guards_universal_only(): void + { + $codes = $this->codesFor(SignatureContractGuards::class); + $this->assertContains('max_one_identity_key_per_target_entity', $codes); + } + + public function test_user_profile_guards_universal_only(): void + { + $codes = $this->codesFor(UserProfileGuards::class); + $this->assertContains('max_one_identity_key_per_target_entity', $codes); + } + + /** + * @param class-string $providerClass + * @return list + */ + private function codesFor(string $providerClass): array + { + $provider = $this->app->make($providerClass); + + return array_map( + static fn (PublishGuard $g): string => $g->code(), + $provider->publishGuards(), + ); + } +}