From 800b1b6c018b07590a7be2f2955b52c2e9b639b1 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 24 Apr 2026 22:12:08 +0200 Subject: [PATCH] feat(form-builder): FormFieldValidationRuleService + legacy backfill + snapshot + library row-copy --- .../UnknownValidationRuleTypeException.php | 17 + .../FormBuilder/FormFieldLibraryResource.php | 5 +- .../FormBuilder/FormFieldResource.php | 5 +- .../FormBuilder/PublicFormSchemaResource.php | 6 +- .../Services/FormBuilder/FormFieldService.php | 2 + .../FormFieldValidationRuleService.php | 312 ++++++++++++++++ .../FormBuilder/FormSubmissionService.php | 5 +- ...1_backfill_form_field_validation_rules.php | 342 ++++++++++++++++++ api/database/seeders/FormBuilderDevSeeder.php | 23 +- .../FormFieldBindingMigrationTest.php | 18 +- .../Public/PublicFormSeederTest.php | 6 +- .../FormFieldValidationRuleBackfillTest.php | 273 ++++++++++++++ .../FormFieldValidationRuleServiceTest.php | 206 +++++++++++ .../InsertFromLibraryCopiesRulesTest.php | 70 ++++ ...chemaSnapshotValidationRulesParityTest.php | 83 +++++ .../ValidationRulesResourceParityTest.php | 75 ++++ 16 files changed, 1430 insertions(+), 18 deletions(-) create mode 100644 api/app/Exceptions/FormBuilder/UnknownValidationRuleTypeException.php create mode 100644 api/app/Services/FormBuilder/FormFieldValidationRuleService.php create mode 100644 api/database/migrations/2026_04_25_110001_backfill_form_field_validation_rules.php create mode 100644 api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php create mode 100644 api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleServiceTest.php create mode 100644 api/tests/Feature/FormBuilder/ValidationRules/InsertFromLibraryCopiesRulesTest.php create mode 100644 api/tests/Feature/FormBuilder/ValidationRules/SchemaSnapshotValidationRulesParityTest.php create mode 100644 api/tests/Feature/FormBuilder/ValidationRules/ValidationRulesResourceParityTest.php diff --git a/api/app/Exceptions/FormBuilder/UnknownValidationRuleTypeException.php b/api/app/Exceptions/FormBuilder/UnknownValidationRuleTypeException.php new file mode 100644 index 00000000..004c61e5 --- /dev/null +++ b/api/app/Exceptions/FormBuilder/UnknownValidationRuleTypeException.php @@ -0,0 +1,17 @@ + $this->label, 'help_text' => $this->help_text, 'options' => $this->options, - 'validation_rules' => $this->validation_rules, + 'validation_rules' => app(FormFieldValidationRuleService::class)->toJsonShape( + $this->resource->validationRules, + ), 'default_is_required' => (bool) $this->default_is_required, 'default_is_filterable' => (bool) $this->default_is_filterable, 'default_binding' => app(FormFieldBindingService::class)->toJsonShape( diff --git a/api/app/Http/Resources/FormBuilder/FormFieldResource.php b/api/app/Http/Resources/FormBuilder/FormFieldResource.php index 74a552b3..5d7db3a7 100644 --- a/api/app/Http/Resources/FormBuilder/FormFieldResource.php +++ b/api/app/Http/Resources/FormBuilder/FormFieldResource.php @@ -8,6 +8,7 @@ use App\Enums\FormBuilder\FormFieldType; use App\Models\FormBuilder\FormField; use App\Models\PersonTag; use App\Services\FormBuilder\FormFieldBindingService; +use App\Services\FormBuilder\FormFieldValidationRuleService; use App\Services\FormBuilder\FormLocaleResolver; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -42,7 +43,9 @@ final class FormFieldResource extends JsonResource $this->field_type === FormFieldType::TAG_PICKER->value, fn () => $this->availableTags(), ), - 'validation_rules' => $this->validation_rules, + 'validation_rules' => app(FormFieldValidationRuleService::class)->toJsonShape( + $this->resource->validationRules, + ), 'is_required' => (bool) $this->is_required, 'is_filterable' => (bool) $this->is_filterable, 'is_portal_visible' => (bool) $this->is_portal_visible, diff --git a/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php b/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php index 2bd95758..fcadb09f 100644 --- a/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php +++ b/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php @@ -9,6 +9,7 @@ use App\Models\FormBuilder\FormField; use App\Models\FormBuilder\FormSchema; use App\Models\PersonTag; use App\Models\Scopes\OrganisationScope; +use App\Services\FormBuilder\FormFieldValidationRuleService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -37,6 +38,7 @@ final class PublicFormSchemaResource extends JsonResource // already happens at PublicFormTokenResolver::resolve(). $this->resource->loadMissing([ 'fields' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class), + 'fields.validationRules', 'sections' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class), ]); @@ -79,7 +81,9 @@ final class PublicFormSchemaResource extends JsonResource 'available_tags' => $isTagPicker ? $this->tagsForField($f, $availableTagsByCategory) : null, - 'validation_rules' => $f->validation_rules, + 'validation_rules' => app(FormFieldValidationRuleService::class)->toJsonShape( + $f->validationRules, + ), 'is_required' => (bool) $f->is_required, 'display_width' => $f->display_width instanceof \BackedEnum ? $f->display_width->value : $f->display_width, 'conditional_logic' => $f->conditional_logic, diff --git a/api/app/Services/FormBuilder/FormFieldService.php b/api/app/Services/FormBuilder/FormFieldService.php index 53128b36..c8d918b3 100644 --- a/api/app/Services/FormBuilder/FormFieldService.php +++ b/api/app/Services/FormBuilder/FormFieldService.php @@ -27,6 +27,7 @@ final class FormFieldService public function __construct( private readonly FormSchemaService $schemaService, private readonly FormFieldBindingService $bindingService, + private readonly FormFieldValidationRuleService $validationRuleService, ) {} public function create(FormSchema $schema, array $data): FormField @@ -216,6 +217,7 @@ final class FormFieldService $field = FormField::create($data); $this->bindingService->copyBindings($library, $field); + $this->validationRuleService->copyRules($library, $field); FormFieldLibrary::query()->whereKey($library->id)->increment('usage_count'); diff --git a/api/app/Services/FormBuilder/FormFieldValidationRuleService.php b/api/app/Services/FormBuilder/FormFieldValidationRuleService.php new file mode 100644 index 00000000..fc68576e --- /dev/null +++ b/api/app/Services/FormBuilder/FormFieldValidationRuleService.php @@ -0,0 +1,312 @@ + + */ + public function rulesFor(FormField|FormFieldLibrary $owner): Collection + { + $type = $this->ownerTypeFor($owner); + + return FormFieldValidationRule::query() + ->where('owner_type', $type) + ->where('owner_id', $owner->getKey()) + ->get(); + } + + /** + * Replace the full rule set on an owner transactionally. Validates every + * spec's rule_type + parameter shape before any write lands. + * + * @param list,error_message_key?:?string}> $specs + */ + public function replaceRules(FormField|FormFieldLibrary $owner, array $specs): void + { + foreach ($specs as $spec) { + $this->assertSpecValid($spec); + } + + $ownerType = $this->ownerTypeFor($owner); + + DB::transaction(function () use ($owner, $ownerType, $specs): void { + FormFieldValidationRule::query() + ->withoutGlobalScopes() + ->where('owner_type', $ownerType) + ->where('owner_id', $owner->getKey()) + ->delete(); + + foreach ($specs as $spec) { + FormFieldValidationRule::query()->withoutGlobalScopes()->create([ + 'owner_type' => $ownerType, + 'owner_id' => $owner->getKey(), + 'rule_type' => $spec['rule_type'], + 'parameters' => $spec['parameters'] ?? [], + 'error_message_key' => $spec['error_message_key'] ?? null, + ]); + } + + if ($owner instanceof FormField) { + $owner->logFieldChange('field.validation_rules_replaced', [ + 'count' => count($specs), + ]); + } + }); + } + + /** + * Row-copy from a library entry to a freshly-inserted field (addendum + * Q3 row-copy mandate). Every column is preserved; only `owner_type` / + * `owner_id` change. + */ + public function copyRules(FormFieldLibrary $from, FormField $to): void + { + $rules = $this->rulesFor($from); + + if ($rules->isEmpty()) { + return; + } + + DB::transaction(function () use ($rules, $to): void { + foreach ($rules as $rule) { + FormFieldValidationRule::query()->withoutGlobalScopes()->create([ + 'owner_type' => 'form_field', + 'owner_id' => $to->id, + 'rule_type' => $rule->rule_type instanceof FormFieldValidationRuleType + ? $rule->rule_type->value + : (string) $rule->rule_type, + 'parameters' => (array) $rule->parameters, + 'error_message_key' => $rule->error_message_key, + ]); + } + }); + } + + /** + * Serialise a rule collection to the flat JSON bag shape consumed by + * snapshot writer and API resources. Returns null on empty — matches + * pre-WS-5b `validation_rules: null`. + * + * Shape uses the canonical rule_type keys (new post-WS-5b names — the + * legacy ambiguous `min` / `max` were renamed at backfill to + * `min_length`/`min_value` etc., and `max_priorities` canonicalised + * to `max_selected`). + * + * @param Collection $rules + * @return array|null + */ + public function toJsonShape(Collection $rules): ?array + { + if ($rules->isEmpty()) { + return null; + } + + $out = []; + foreach ($rules as $rule) { + $type = $rule->rule_type instanceof FormFieldValidationRuleType + ? $rule->rule_type->value + : (string) $rule->rule_type; + $params = (array) $rule->parameters; + + $out[$type] = $this->flattenParameters($type, $params); + } + + return $out; + } + + /** + * Canonical flat rendering per rule_type — mirrors the pre-WS-5b bag + * shape convention (scalar value for single-value rules, array for + * collections, boolean `true` for no-param markers). + * + * @param array $params + */ + private function flattenParameters(string $ruleType, array $params): mixed + { + return match ($ruleType) { + FormFieldValidationRuleType::MinLength->value, + FormFieldValidationRuleType::MaxLength->value, + FormFieldValidationRuleType::MinValue->value, + FormFieldValidationRuleType::MaxValue->value, + FormFieldValidationRuleType::MinSelected->value, + FormFieldValidationRuleType::MaxSelected->value => $params['value'] ?? null, + + FormFieldValidationRuleType::MaxFileSize->value => $params['bytes'] ?? null, + + FormFieldValidationRuleType::Regex->value => isset($params['flags']) && $params['flags'] !== '' + ? ['pattern' => $params['pattern'] ?? '', 'flags' => $params['flags']] + : ($params['pattern'] ?? ''), + + FormFieldValidationRuleType::AllowedMimeTypes->value => $params['mime_types'] ?? [], + + FormFieldValidationRuleType::DateMin->value, + FormFieldValidationRuleType::DateMax->value => $params['date'] ?? null, + + FormFieldValidationRuleType::Callback->value => $params['key'] ?? '', + + FormFieldValidationRuleType::EmailFormat->value, + FormFieldValidationRuleType::UrlFormat->value, + FormFieldValidationRuleType::PhoneE164->value => true, + + default => $params, + }; + } + + private function ownerTypeFor(FormField|FormFieldLibrary $owner): string + { + return $owner instanceof FormField ? 'form_field' : 'form_field_library'; + } + + /** @param array $spec */ + private function assertSpecValid(array $spec): void + { + $ruleTypeRaw = (string) ($spec['rule_type'] ?? ''); + $enum = FormFieldValidationRuleType::tryFrom($ruleTypeRaw); + if ($enum === null) { + throw new UnknownValidationRuleTypeException( + "Validation rule_type '{$ruleTypeRaw}' is not a registered " + .'FormFieldValidationRuleType case.', + ); + } + + $params = (array) ($spec['parameters'] ?? []); + $this->assertParametersMatchShape($enum, $params); + } + + /** @param array $params */ + private function assertParametersMatchShape(FormFieldValidationRuleType $type, array $params): void + { + switch ($type) { + case FormFieldValidationRuleType::MinLength: + case FormFieldValidationRuleType::MaxLength: + case FormFieldValidationRuleType::MinSelected: + case FormFieldValidationRuleType::MaxSelected: + $this->requireInt($type, $params, 'value'); + + return; + + case FormFieldValidationRuleType::MinValue: + case FormFieldValidationRuleType::MaxValue: + $this->requireNumeric($type, $params, 'value'); + + return; + + case FormFieldValidationRuleType::MaxFileSize: + $this->requireInt($type, $params, 'bytes'); + + return; + + case FormFieldValidationRuleType::Regex: + $this->requireString($type, $params, 'pattern'); + + return; + + case FormFieldValidationRuleType::AllowedMimeTypes: + if (! isset($params['mime_types']) || ! is_array($params['mime_types'])) { + throw new UnknownValidationRuleTypeException( + "Validation rule '{$type->value}' requires parameters.mime_types (array of strings).", + ); + } + foreach ($params['mime_types'] as $mime) { + if (! is_string($mime) || $mime === '') { + throw new UnknownValidationRuleTypeException( + "Validation rule '{$type->value}' parameters.mime_types must be non-empty strings.", + ); + } + } + + return; + + case FormFieldValidationRuleType::DateMin: + case FormFieldValidationRuleType::DateMax: + $this->requireString($type, $params, 'date'); + + return; + + case FormFieldValidationRuleType::Callback: + $this->requireString($type, $params, 'key'); + $registered = (array) config('form_builder.validation_callbacks', []); + if (! array_key_exists($params['key'], $registered)) { + throw new UnknownValidationRuleTypeException( + "Validation callback '{$params['key']}' is not registered in " + .'config/form_builder.php under `validation_callbacks`.', + ); + } + + return; + + case FormFieldValidationRuleType::EmailFormat: + case FormFieldValidationRuleType::UrlFormat: + case FormFieldValidationRuleType::PhoneE164: + // Boolean markers — parameters must be empty or absent. Any + // unexpected keys signal a caller bug; reject loudly. + if ($params !== []) { + throw new UnknownValidationRuleTypeException( + "Validation rule '{$type->value}' takes no parameters; got ".count($params).'.', + ); + } + + return; + } + } + + /** @param array $params */ + private function requireInt(FormFieldValidationRuleType $type, array $params, string $key): void + { + if (! isset($params[$key]) || ! is_int($params[$key])) { + throw new UnknownValidationRuleTypeException( + "Validation rule '{$type->value}' requires integer parameters.{$key}.", + ); + } + } + + /** @param array $params */ + private function requireNumeric(FormFieldValidationRuleType $type, array $params, string $key): void + { + if (! isset($params[$key]) || ! is_numeric($params[$key])) { + throw new UnknownValidationRuleTypeException( + "Validation rule '{$type->value}' requires numeric parameters.{$key}.", + ); + } + } + + /** @param array $params */ + private function requireString(FormFieldValidationRuleType $type, array $params, string $key): void + { + if (! isset($params[$key]) || ! is_string($params[$key]) || $params[$key] === '') { + throw new UnknownValidationRuleTypeException( + "Validation rule '{$type->value}' requires non-empty string parameters.{$key}.", + ); + } + } +} diff --git a/api/app/Services/FormBuilder/FormSubmissionService.php b/api/app/Services/FormBuilder/FormSubmissionService.php index a9f149d7..90dd1e4d 100644 --- a/api/app/Services/FormBuilder/FormSubmissionService.php +++ b/api/app/Services/FormBuilder/FormSubmissionService.php @@ -34,6 +34,7 @@ final class FormSubmissionService private readonly FormLocaleResolver $localeResolver, private readonly FormValueService $valueService, private readonly FormFieldBindingService $bindingService, + private readonly FormFieldValidationRuleService $validationRuleService, ) {} /** @@ -200,7 +201,7 @@ final class FormSubmissionService */ private function buildSnapshot(FormSchema $schema): array { - $schema->loadMissing(['fields.bindings', 'sections']); + $schema->loadMissing(['fields.bindings', 'fields.validationRules', 'sections']); return [ 'schema_version' => $schema->version, @@ -232,7 +233,7 @@ final class FormSubmissionService 'help_text' => $f->help_text, 'section_slug' => $this->sectionSlug($schema, $f->form_schema_section_id), 'options' => $f->options, - 'validation_rules' => $f->validation_rules, + 'validation_rules' => $this->validationRuleService->toJsonShape($f->validationRules), 'is_required' => (bool) $f->is_required, 'is_filterable' => (bool) $f->is_filterable, 'is_pii' => (bool) $f->is_pii, diff --git a/api/database/migrations/2026_04_25_110001_backfill_form_field_validation_rules.php b/api/database/migrations/2026_04_25_110001_backfill_form_field_validation_rules.php new file mode 100644 index 00000000..3ab33fba --- /dev/null +++ b/api/database/migrations/2026_04_25_110001_backfill_form_field_validation_rules.php @@ -0,0 +1,342 @@ +backfill('form_fields', 'form_field'); + $this->backfill('form_field_library', 'form_field_library'); + }); + } + + public function down(): void + { + if (! Schema::hasTable('form_field_validation_rules')) { + return; + } + + DB::transaction(function (): void { + $this->reconstructJson('form_fields', 'form_field'); + $this->reconstructJson('form_field_library', 'form_field_library'); + }); + } + + private function backfill(string $table, string $ownerType): void + { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'validation_rules')) { + return; + } + + $rows = DB::table($table) + ->whereNotNull('validation_rules') + ->orderBy('id') + ->get(['id', 'field_type', 'validation_rules']); + + if ($rows->isEmpty()) { + return; + } + + $now = now(); + $inserts = []; + + foreach ($rows as $row) { + $raw = $row->validation_rules; + $decoded = is_string($raw) ? json_decode($raw, true) : $raw; + if (! is_array($decoded) || $decoded === []) { + continue; + } + + $fieldType = (string) ($row->field_type ?? ''); + + foreach ($decoded as $key => $value) { + $translated = $this->translateKey($ownerType, $row->id, $fieldType, (string) $key, $value); + if ($translated === null) { + continue; + } + + $inserts[] = [ + 'id' => (string) Str::ulid(), + 'owner_type' => $ownerType, + 'owner_id' => (string) $row->id, + 'rule_type' => $translated['rule_type'], + 'parameters' => json_encode($translated['parameters']), + 'error_message_key' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + } + + if ($inserts === []) { + return; + } + + foreach (array_chunk($inserts, 500) as $batch) { + DB::table('form_field_validation_rules')->insert($batch); + } + } + + /** + * @return array{rule_type:string, parameters:array}|null + */ + private function translateKey( + string $ownerType, + string $ownerId, + string $fieldType, + string $key, + mixed $value, + ): ?array { + // Column-owned keys: not migrated. WARN-log defensively; these + // should not appear in the wild per Phase A seed scan. + if ($key === 'required' || $key === 'unique') { + Log::warning(sprintf( + 'form_field_validation_rules backfill: skipping legacy `%s` key on %s/%s — ' + .'column is-%s is the source of truth.', + $key, $ownerType, $ownerId, $key, + )); + + return null; + } + + // Configs keys: handled by commit 5's configs-backfill migration. + if ($key === 'tag_categories' || $key === 'storage_disk') { + Log::info(sprintf( + 'form_field_validation_rules backfill: skipping `%s` key on %s/%s — ' + .'will be picked up by the form_field_configs backfill (WS-5b commit 5).', + $key, $ownerType, $ownerId, + )); + + return null; + } + + // Canonicalisations. + if ($key === 'max_priorities') { + return [ + 'rule_type' => FormFieldValidationRuleType::MaxSelected->value, + 'parameters' => ['value' => (int) $value], + ]; + } + + // Ambiguous legacy min/max → field-type-driven dispatch. + if ($key === 'min' || $key === 'max') { + return $this->dispatchMinMax($ownerType, $ownerId, $fieldType, $key, $value); + } + + // Catalogue-enumerated keys: direct mapping with parameter wrapping. + $enum = FormFieldValidationRuleType::tryFrom($key); + if ($enum === null) { + throw new \RuntimeException(sprintf( + 'form_field_validation_rules backfill: unknown validation rule key `%s` on %s/%s. ' + .'Phase A seed-scan did not surface this; inspect the data and either register ' + .'the key in the FormFieldValidationRuleType enum or clean the source row.', + $key, $ownerType, $ownerId, + )); + } + + return [ + 'rule_type' => $enum->value, + 'parameters' => $this->wrapParameters($enum, $value), + ]; + } + + /** + * @return array{rule_type:string, parameters:array} + */ + private function dispatchMinMax( + string $ownerType, + string $ownerId, + string $fieldType, + string $key, + mixed $value, + ): array { + $isNumeric = in_array($fieldType, [FormFieldType::NUMBER->value], true); + $isString = in_array($fieldType, [ + FormFieldType::TEXT->value, + FormFieldType::TEXTAREA->value, + FormFieldType::EMAIL->value, + FormFieldType::PHONE->value, + FormFieldType::URL->value, + ], true); + $isDate = in_array($fieldType, [ + FormFieldType::DATE->value, + FormFieldType::DATETIME->value, + ], true); + + if ($isNumeric) { + return [ + 'rule_type' => $key === 'min' + ? FormFieldValidationRuleType::MinValue->value + : FormFieldValidationRuleType::MaxValue->value, + 'parameters' => ['value' => is_numeric($value) ? $value + 0 : $value], + ]; + } + + if ($isString) { + return [ + 'rule_type' => $key === 'min' + ? FormFieldValidationRuleType::MinLength->value + : FormFieldValidationRuleType::MaxLength->value, + 'parameters' => ['value' => (int) $value], + ]; + } + + if ($isDate) { + return [ + 'rule_type' => $key === 'min' + ? FormFieldValidationRuleType::DateMin->value + : FormFieldValidationRuleType::DateMax->value, + 'parameters' => ['date' => (string) $value], + ]; + } + + throw new \RuntimeException(sprintf( + 'form_field_validation_rules backfill: cannot dispatch legacy `%s` key on %s/%s with ' + .'field_type `%s`. Only NUMBER, TEXT/TEXTAREA/EMAIL/PHONE/URL, DATE/DATETIME are ' + .'mapped; inspect the source data.', + $key, $ownerType, $ownerId, $fieldType, + )); + } + + /** + * @return array + */ + private function wrapParameters(FormFieldValidationRuleType $type, mixed $value): array + { + return match ($type) { + FormFieldValidationRuleType::MinLength, + FormFieldValidationRuleType::MaxLength, + FormFieldValidationRuleType::MinSelected, + FormFieldValidationRuleType::MaxSelected => ['value' => (int) $value], + + FormFieldValidationRuleType::MinValue, + FormFieldValidationRuleType::MaxValue => ['value' => is_numeric($value) ? $value + 0 : $value], + + FormFieldValidationRuleType::MaxFileSize => ['bytes' => (int) $value], + + FormFieldValidationRuleType::Regex => is_array($value) + ? array_filter([ + 'pattern' => (string) ($value['pattern'] ?? ''), + 'flags' => (string) ($value['flags'] ?? ''), + ], static fn ($v): bool => $v !== '') + : ['pattern' => (string) $value], + + FormFieldValidationRuleType::AllowedMimeTypes => [ + 'mime_types' => is_array($value) + ? array_values(array_map(static fn ($m): string => (string) $m, $value)) + : [], + ], + + FormFieldValidationRuleType::DateMin, + FormFieldValidationRuleType::DateMax => ['date' => (string) $value], + + FormFieldValidationRuleType::Callback => ['key' => (string) $value], + + FormFieldValidationRuleType::EmailFormat, + FormFieldValidationRuleType::UrlFormat, + FormFieldValidationRuleType::PhoneE164 => [], + }; + } + + private function reconstructJson(string $table, string $ownerType): void + { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'validation_rules')) { + return; + } + + $rows = DB::table('form_field_validation_rules') + ->where('owner_type', $ownerType) + ->orderBy('owner_id') + ->get(); + + if ($rows->isEmpty()) { + return; + } + + $grouped = []; + foreach ($rows as $row) { + $ownerId = (string) $row->owner_id; + $grouped[$ownerId] ??= []; + $params = json_decode((string) $row->parameters, true); + $params = is_array($params) ? $params : []; + $grouped[$ownerId][(string) $row->rule_type] = $this->flattenForJson((string) $row->rule_type, $params); + } + + foreach ($grouped as $ownerId => $bag) { + DB::table($table) + ->where('id', $ownerId) + ->update(['validation_rules' => json_encode($bag)]); + } + } + + /** @param array $params */ + private function flattenForJson(string $ruleType, array $params): mixed + { + return match ($ruleType) { + 'min_length', 'max_length', 'min_value', 'max_value', + 'min_selected', 'max_selected' => $params['value'] ?? null, + + 'max_file_size' => $params['bytes'] ?? null, + + 'regex' => isset($params['flags']) && $params['flags'] !== '' + ? ['pattern' => $params['pattern'] ?? '', 'flags' => $params['flags']] + : ($params['pattern'] ?? ''), + + 'allowed_mime_types' => $params['mime_types'] ?? [], + + 'date_min', 'date_max' => $params['date'] ?? null, + + 'callback' => $params['key'] ?? '', + + 'email_format', 'url_format', 'phone_e164' => true, + + default => $params, + }; + } +}; diff --git a/api/database/seeders/FormBuilderDevSeeder.php b/api/database/seeders/FormBuilderDevSeeder.php index 908820b2..9eb1d0f8 100644 --- a/api/database/seeders/FormBuilderDevSeeder.php +++ b/api/database/seeders/FormBuilderDevSeeder.php @@ -308,9 +308,12 @@ final class FormBuilderDevSeeder 'slug' => 'sectie_voorkeur', 'label' => 'Bij welke sectie wil je het liefst werken?', 'help_text' => 'Sleep je voorkeuren in volgorde. Nummer 1 is je eerste keuze.', - // UI soft cap; the hard cap of 5 lives in - // FormValueService shape validation. - 'validation_rules' => ['max_priorities' => 3], + // UI soft cap (hard cap of 5 is in FormValueService shape + // validation). Post-WS-5b the UI cap lives as a relational + // row with rule_type `max_selected` — see + // seedEventRegistrationShowcaseSchema() which populates it + // via FormFieldValidationRuleService. + 'max_selected_ui_cap' => 3, 'is_required' => false, 'is_filterable' => false, 'display_width' => 'full', @@ -357,8 +360,10 @@ final class FormBuilderDevSeeder 'version' => 1, ]); + $ruleService = app(\App\Services\FormBuilder\FormFieldValidationRuleService::class); + foreach (self::showcaseFieldDefinitions() as $sortOrder => $def) { - FormField::create([ + $field = FormField::create([ 'form_schema_id' => $schema->id, 'field_type' => $def['type']->value, 'slug' => $def['slug'], @@ -376,6 +381,16 @@ final class FormBuilderDevSeeder 'value_storage_hint' => $def['value_storage_hint'] ?? FormValueStorageHint::JSON, 'sort_order' => $sortOrder + 1, ]); + + // Relational validation rules (WS-5b). The SECTION_PRIORITY + // field carries the UI soft cap as a `max_selected` row; other + // fields in the showcase have no rules yet. + if (isset($def['max_selected_ui_cap'])) { + $ruleService->replaceRules($field, [[ + 'rule_type' => \App\Enums\FormBuilder\FormFieldValidationRuleType::MaxSelected->value, + 'parameters' => ['value' => (int) $def['max_selected_ui_cap']], + ]]); + } } return $schema->refresh(); diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index 5615dffd..88c93507 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -33,9 +33,10 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_forward_migrations_backfill_rows_from_both_json_sources(): void { - // Roll back: create_form_field_validation_rules_table (WS-5b commit 1) + // Roll back, newest first: backfill_form_field_validation_rules + // → create_form_field_validation_rules_table (both WS-5b commit 1+2) // → drop_binding_json_columns → create_form_field_bindings. - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding')); @@ -96,9 +97,10 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_rollback_reconstructs_json_and_drops_table(): void { - // Walk back: validation-rules table (WS-5b commit 1) → - // drop_binding_json_columns → create_form_field_bindings. - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + // Walk back the full WS-5b + WS-5a stack: backfill (validation rules) + // → create (validation rules table) → drop (binding columns) → + // create (bindings table). + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); [$fieldAId, , ] = $this->seedFieldsWithBindingJson(); [$libAId, ] = $this->seedLibraryWithBindingJson(); @@ -108,9 +110,9 @@ final class FormFieldBindingMigrationTest extends TestCase $this->assertFalse(Schema::hasColumn('form_fields', 'binding')); $this->assertSame(5, DB::table('form_field_bindings')->count()); - // Step back over WS-5b validation-rules table → irrelevant to the - // binding contract, but restores the pre-WS-5b state. - $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + // Step back over the two WS-5b migrations → restores the pre-WS-5b + // state (validation-rules table gone; binding contract intact). + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertTrue(Schema::hasTable('form_field_bindings')); diff --git a/api/tests/Feature/FormBuilder/Public/PublicFormSeederTest.php b/api/tests/Feature/FormBuilder/Public/PublicFormSeederTest.php index 01fb8956..79116124 100644 --- a/api/tests/Feature/FormBuilder/Public/PublicFormSeederTest.php +++ b/api/tests/Feature/FormBuilder/Public/PublicFormSeederTest.php @@ -187,7 +187,11 @@ final class PublicFormSeederTest extends TestCase $this->assertArrayHasKey('sectie_voorkeur', $fields); $this->assertSame(FormFieldType::SECTION_PRIORITY->value, $fields['sectie_voorkeur']['field_type']); $this->assertSame('full', $fields['sectie_voorkeur']['display_width']); - $this->assertSame(['max_priorities' => 3], $fields['sectie_voorkeur']['validation_rules']); + // WS-5b canonicalised `max_priorities` → `max_selected` (shared + // semantic of "cap on entries in a list-valued field"). Resource + // emits the new canonical key via + // FormFieldValidationRuleService::toJsonShape(). + $this->assertSame(['max_selected' => 3], $fields['sectie_voorkeur']['validation_rules']); } public function test_time_slots_endpoint_returns_at_least_four_volunteer_rows(): void diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php new file mode 100644 index 00000000..17394131 --- /dev/null +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php @@ -0,0 +1,273 @@ +artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + $this->assertFalse(Schema::hasTable('form_field_validation_rules')); + $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); + + [$numberId, $textId, $dateId, $sectionPriorityId] = $this->seedFields(); + [$libAId] = $this->seedLibrary(); + + $this->artisan('migrate')->assertSuccessful(); + + $this->assertTrue(Schema::hasTable('form_field_validation_rules')); + + $numberRules = DB::table('form_field_validation_rules') + ->where('owner_type', 'form_field') + ->where('owner_id', $numberId) + ->get()->keyBy('rule_type'); + $this->assertTrue($numberRules->has('min_value')); + $this->assertTrue($numberRules->has('max_value')); + $this->assertSame(16, json_decode((string) $numberRules['min_value']->parameters, true)['value']); + $this->assertSame(99, json_decode((string) $numberRules['max_value']->parameters, true)['value']); + + $textRules = DB::table('form_field_validation_rules') + ->where('owner_type', 'form_field') + ->where('owner_id', $textId) + ->get()->keyBy('rule_type'); + $this->assertTrue($textRules->has('min_length')); + $this->assertTrue($textRules->has('max_length')); + $this->assertSame(5, json_decode((string) $textRules['min_length']->parameters, true)['value']); + $this->assertSame(80, json_decode((string) $textRules['max_length']->parameters, true)['value']); + + $dateRules = DB::table('form_field_validation_rules') + ->where('owner_type', 'form_field') + ->where('owner_id', $dateId) + ->get()->keyBy('rule_type'); + $this->assertTrue($dateRules->has('date_min')); + $this->assertSame('2026-01-01', json_decode((string) $dateRules['date_min']->parameters, true)['date']); + + $sectionRules = DB::table('form_field_validation_rules') + ->where('owner_type', 'form_field') + ->where('owner_id', $sectionPriorityId) + ->get()->keyBy('rule_type'); + $this->assertTrue($sectionRules->has('max_selected'), 'max_priorities should canonicalise to max_selected'); + $this->assertSame(3, json_decode((string) $sectionRules['max_selected']->parameters, true)['value']); + $this->assertFalse($sectionRules->has('max_priorities'), 'legacy key must not survive'); + + $libRules = DB::table('form_field_validation_rules') + ->where('owner_type', 'form_field_library') + ->where('owner_id', $libAId) + ->get()->keyBy('rule_type'); + $this->assertTrue($libRules->has('regex')); + $this->assertSame( + '/^[A-Z]{3}$/', + json_decode((string) $libRules['regex']->parameters, true)['pattern'], + ); + } + + public function test_tag_categories_and_storage_disk_skipped_for_commit_5(): void + { + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + + $fieldId = $this->seedFieldWithJson([ + 'field_type' => 'TAG_PICKER', + 'validation_rules' => [ + 'tag_categories' => ['Veiligheid'], + 'storage_disk' => 'local', + ], + ]); + + $this->artisan('migrate')->assertSuccessful(); + + $rows = DB::table('form_field_validation_rules') + ->where('owner_id', $fieldId) + ->get(); + $this->assertCount(0, $rows, 'configs keys must not land in the validation-rules table'); + } + + public function test_required_and_unique_skipped_with_warn(): void + { + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + + $fieldId = $this->seedFieldWithJson([ + 'field_type' => 'TEXT', + 'validation_rules' => [ + 'required' => true, + 'unique' => true, + 'min' => 2, + ], + ]); + + $this->artisan('migrate')->assertSuccessful(); + + $rows = DB::table('form_field_validation_rules') + ->where('owner_id', $fieldId) + ->pluck('rule_type') + ->all(); + sort($rows); + $this->assertSame(['min_length'], $rows, 'only min_length should land (required/unique skipped)'); + } + + public function test_unknown_top_level_key_fails_migration(): void + { + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + + $this->seedFieldWithJson([ + 'field_type' => 'TEXT', + 'validation_rules' => ['nonsense_key' => 42], + ]); + + $this->expectException(\RuntimeException::class); + $this->artisan('migrate'); + } + + public function test_unmapped_field_type_for_min_max_fails_migration(): void + { + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + + $this->seedFieldWithJson([ + 'field_type' => 'BOOLEAN', + 'validation_rules' => ['min' => 1], + ]); + + $this->expectException(\RuntimeException::class); + $this->artisan('migrate'); + } + + public function test_rollback_reconstructs_canonical_json_on_source_tables(): void + { + $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + [$numberId] = $this->seedFields(); + + $this->artisan('migrate')->assertSuccessful(); + + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + // Only the backfill rolled back — the create-table migration still + // applied, so rows remain accessible (until we step back once more). + $this->assertTrue(Schema::hasTable('form_field_validation_rules')); + + $field = DB::table('form_fields')->where('id', $numberId)->first(); + $decoded = json_decode((string) $field->validation_rules, true); + // Rollback reconstructs using canonical keys — the legacy `min`/`max` + // are intentionally NOT resurrected (post-rename semantic). + $this->assertSame(16, $decoded['min_value']); + $this->assertSame(99, $decoded['max_value']); + } + + /** @return array{0:string,1:string,2:string,3:string} */ + private function seedFields(): array + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + + $number = (string) Str::ulid(); + $text = (string) Str::ulid(); + $date = (string) Str::ulid(); + $section = (string) Str::ulid(); + + DB::table('form_fields')->insert([ + $this->row($number, $schema->id, 'NUMBER', 'leeftijd', + ['min' => 16, 'max' => 99]), + $this->row($text, $schema->id, 'TEXT', 'postcode', + ['min' => 5, 'max' => 80]), + $this->row($date, $schema->id, 'DATE', 'startdatum', + ['min' => '2026-01-01']), + $this->row($section, $schema->id, 'SECTION_PRIORITY', 'sectie-voorkeur', + ['max_priorities' => 3]), + ]); + + return [$number, $text, $date, $section]; + } + + /** @param array $validationRules */ + private function row(string $id, string $schemaId, string $fieldType, string $slug, array $validationRules): array + { + return [ + 'id' => $id, + 'form_schema_id' => $schemaId, + 'field_type' => $fieldType, + 'slug' => $slug, + 'label' => $slug, + 'validation_rules' => json_encode($validationRules), + 'value_storage_hint' => 'json', + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + /** @return array{0:string} */ + private function seedLibrary(): array + { + $org = Organisation::factory()->create(); + + $lib = (string) Str::ulid(); + DB::table('form_field_library')->insert([ + [ + 'id' => $lib, + 'organisation_id' => $org->id, + 'name' => 'License plate bibliotheek', + 'slug' => 'kenteken-lib', + 'field_type' => 'TEXT', + 'label' => 'Kenteken', + 'validation_rules' => json_encode(['regex' => '/^[A-Z]{3}$/']), + 'default_is_required' => false, + 'default_is_filterable' => false, + 'usage_count' => 0, + 'is_system' => false, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + return [$lib]; + } + + /** @param array $attrs */ + private function seedFieldWithJson(array $attrs): string + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + + $id = (string) Str::ulid(); + DB::table('form_fields')->insert([[ + 'id' => $id, + 'form_schema_id' => $schema->id, + 'field_type' => $attrs['field_type'], + 'slug' => 'f-'.Str::lower(Str::random(4)), + 'label' => 'field', + 'validation_rules' => json_encode($attrs['validation_rules']), + 'value_storage_hint' => 'json', + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]]); + + return $id; + } +} diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleServiceTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleServiceTest.php new file mode 100644 index 00000000..5ac6cdeb --- /dev/null +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleServiceTest.php @@ -0,0 +1,206 @@ +service = app(FormFieldValidationRuleService::class); + } + + public function test_replace_rules_is_transactional_delete_then_insert(): void + { + $field = $this->makeField(); + FormFieldValidationRule::factory()->forField($field) + ->ofType(FormFieldValidationRuleType::MinLength, ['value' => 1])->create(); + + $this->service->replaceRules($field, [ + ['rule_type' => 'min_length', 'parameters' => ['value' => 5]], + ['rule_type' => 'max_length', 'parameters' => ['value' => 40]], + ]); + + $rules = $this->service->rulesFor($field); + $this->assertCount(2, $rules); + $this->assertSame(5, $rules->firstWhere('rule_type', FormFieldValidationRuleType::MinLength)->parameters['value']); + $this->assertSame(40, $rules->firstWhere('rule_type', FormFieldValidationRuleType::MaxLength)->parameters['value']); + } + + public function test_empty_specs_array_clears_all_rules(): void + { + $field = $this->makeField(); + FormFieldValidationRule::factory()->forField($field)->create(); + FormFieldValidationRule::factory()->forField($field) + ->ofType(FormFieldValidationRuleType::MaxLength, ['value' => 10])->create(); + + $this->service->replaceRules($field, []); + + $this->assertCount(0, $this->service->rulesFor($field)); + } + + public function test_unknown_rule_type_is_rejected(): void + { + $field = $this->makeField(); + + $this->expectException(UnknownValidationRuleTypeException::class); + $this->service->replaceRules($field, [ + ['rule_type' => 'not_a_real_rule', 'parameters' => []], + ]); + } + + public function test_bad_parameter_shape_is_rejected(): void + { + $field = $this->makeField(); + + $this->expectException(UnknownValidationRuleTypeException::class); + $this->service->replaceRules($field, [ + ['rule_type' => 'min_length', 'parameters' => ['value' => 'not-an-int']], + ]); + } + + public function test_allowed_mime_types_requires_array(): void + { + $field = $this->makeField(); + + $this->expectException(UnknownValidationRuleTypeException::class); + $this->service->replaceRules($field, [ + ['rule_type' => 'allowed_mime_types', 'parameters' => ['mime_types' => 'not-an-array']], + ]); + } + + public function test_callback_rejects_unregistered_key(): void + { + Config::set('form_builder.validation_callbacks', []); + $field = $this->makeField(); + + $this->expectException(UnknownValidationRuleTypeException::class); + $this->service->replaceRules($field, [ + ['rule_type' => 'callback', 'parameters' => ['key' => 'unregistered_callback']], + ]); + } + + public function test_callback_accepts_registered_key(): void + { + Config::set('form_builder.validation_callbacks', [ + 'kvk_lookup' => \stdClass::class, + ]); + $field = $this->makeField(); + + $this->service->replaceRules($field, [ + ['rule_type' => 'callback', 'parameters' => ['key' => 'kvk_lookup']], + ]); + + $rules = $this->service->rulesFor($field); + $this->assertSame('kvk_lookup', $rules->first()->parameters['key']); + } + + public function test_replace_emits_field_validation_rules_replaced_activity_log(): void + { + $field = $this->makeField(); + + $this->service->replaceRules($field, [ + ['rule_type' => 'min_length', 'parameters' => ['value' => 3]], + ]); + + $entry = Activity::query() + ->where('subject_type', 'form_field') + ->where('subject_id', $field->id) + ->where('description', 'field.validation_rules_replaced') + ->first(); + $this->assertNotNull($entry); + $this->assertSame(1, (int) $entry->properties['count']); + } + + public function test_replace_on_library_does_not_emit_field_activity_log(): void + { + // Convention-match with WS-5a: library-level changes are silent in + // activity log; only the FormField subject gets the semantic event. + $library = FormFieldLibrary::factory()->create([ + 'organisation_id' => Organisation::factory()->create()->id, + ]); + + $this->service->replaceRules($library, [ + ['rule_type' => 'min_length', 'parameters' => ['value' => 3]], + ]); + + $entry = Activity::query() + ->where('subject_type', 'form_field_library') + ->where('description', 'field.validation_rules_replaced') + ->first(); + $this->assertNull($entry); + } + + public function test_copy_rules_clones_every_column(): void + { + $org = Organisation::factory()->create(); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + FormFieldValidationRule::factory()->forLibrary($library) + ->ofType(FormFieldValidationRuleType::MinLength, ['value' => 3])->create(); + FormFieldValidationRule::factory()->forLibrary($library) + ->ofType(FormFieldValidationRuleType::MaxLength, ['value' => 40])->create(); + + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $this->service->copyRules($library, $field); + + $rules = $this->service->rulesFor($field); + $this->assertCount(2, $rules); + $this->assertSame(3, $rules->firstWhere('rule_type', FormFieldValidationRuleType::MinLength)->parameters['value']); + $this->assertSame(40, $rules->firstWhere('rule_type', FormFieldValidationRuleType::MaxLength)->parameters['value']); + } + + public function test_to_json_shape_empty_collection_returns_null(): void + { + $field = $this->makeField(); + $this->assertNull($this->service->toJsonShape($this->service->rulesFor($field))); + } + + public function test_to_json_shape_flattens_known_rule_types(): void + { + $field = $this->makeField(); + FormFieldValidationRule::factory()->forField($field) + ->ofType(FormFieldValidationRuleType::MinLength, ['value' => 5])->create(); + FormFieldValidationRule::factory()->forField($field) + ->ofType(FormFieldValidationRuleType::Regex, ['pattern' => '/^x/'])->create(); + FormFieldValidationRule::factory()->forField($field) + ->ofType(FormFieldValidationRuleType::AllowedMimeTypes, ['mime_types' => ['image/png']])->create(); + FormFieldValidationRule::factory()->forField($field) + ->ofType(FormFieldValidationRuleType::EmailFormat, [])->create(); + + $shape = $this->service->toJsonShape($this->service->rulesFor($field)); + + $this->assertSame(5, $shape['min_length']); + $this->assertSame('/^x/', $shape['regex']); + $this->assertSame(['image/png'], $shape['allowed_mime_types']); + $this->assertTrue($shape['email_format']); + } + + private function makeField(): FormField + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + + return FormField::factory()->create(['form_schema_id' => $schema->id]); + } +} diff --git a/api/tests/Feature/FormBuilder/ValidationRules/InsertFromLibraryCopiesRulesTest.php b/api/tests/Feature/FormBuilder/ValidationRules/InsertFromLibraryCopiesRulesTest.php new file mode 100644 index 00000000..a8bfdf1c --- /dev/null +++ b/api/tests/Feature/FormBuilder/ValidationRules/InsertFromLibraryCopiesRulesTest.php @@ -0,0 +1,70 @@ +create(); + + $library = FormFieldLibrary::factory()->create([ + 'organisation_id' => $org->id, + 'slug' => 'kenteken', + ]); + FormFieldValidationRule::factory()->forLibrary($library) + ->ofType(FormFieldValidationRuleType::Regex, ['pattern' => '/^[A-Z]{3}$/'])->create(); + FormFieldValidationRule::factory()->forLibrary($library) + ->ofType(FormFieldValidationRuleType::MaxLength, ['value' => 8])->create(); + + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + + /** @var FormField $field */ + $field = app(FormFieldService::class)->insertFromLibrary($schema, $library); + + $rules = FormFieldValidationRule::query() + ->where('owner_type', 'form_field') + ->where('owner_id', $field->id) + ->get() + ->keyBy(fn ($r) => $r->rule_type->value); + + $this->assertCount(2, $rules); + $this->assertSame('/^[A-Z]{3}$/', $rules['regex']->parameters['pattern']); + $this->assertSame(8, $rules['max_length']->parameters['value']); + } + + public function test_library_without_rules_produces_field_without_rules(): void + { + $org = Organisation::factory()->create(); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + + /** @var FormField $field */ + $field = app(FormFieldService::class)->insertFromLibrary($schema, $library); + + $count = FormFieldValidationRule::query() + ->where('owner_type', 'form_field') + ->where('owner_id', $field->id) + ->count(); + $this->assertSame(0, $count); + } +} diff --git a/api/tests/Feature/FormBuilder/ValidationRules/SchemaSnapshotValidationRulesParityTest.php b/api/tests/Feature/FormBuilder/ValidationRules/SchemaSnapshotValidationRulesParityTest.php new file mode 100644 index 00000000..bb8424ee --- /dev/null +++ b/api/tests/Feature/FormBuilder/ValidationRules/SchemaSnapshotValidationRulesParityTest.php @@ -0,0 +1,83 @@ +create(); + $schema = FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'submission_mode' => FormSubmissionMode::SINGLE, + 'snapshot_mode' => FormSchemaSnapshotMode::ON_SUBMIT, + ]); + $field = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'voornaam', + ]); + FormFieldValidationRule::factory()->forField($field) + ->ofType(FormFieldValidationRuleType::MinLength, ['value' => 2])->create(); + FormFieldValidationRule::factory()->forField($field) + ->ofType(FormFieldValidationRuleType::MaxLength, ['value' => 40])->create(); + + $service = app(FormSubmissionService::class); + $snapshot = $this->invokeBuildSnapshot($service, $schema); + + $fieldEntry = collect($snapshot['fields'])->firstWhere('slug', 'voornaam'); + $this->assertNotNull($fieldEntry); + $this->assertSame([ + 'min_length' => 2, + 'max_length' => 40, + ], $fieldEntry['validation_rules']); + } + + public function test_snapshot_validation_rules_null_when_no_rules(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'slug' => 'noot', + ]); + + $service = app(FormSubmissionService::class); + $snapshot = $this->invokeBuildSnapshot($service, $schema); + + $entry = collect($snapshot['fields'])->firstWhere('slug', 'noot'); + $this->assertNull($entry['validation_rules']); + } + + /** @return array */ + private function invokeBuildSnapshot(FormSubmissionService $service, FormSchema $schema): array + { + $ref = new \ReflectionMethod($service, 'buildSnapshot'); + $ref->setAccessible(true); + + return $ref->invoke($service, $schema); + } +} diff --git a/api/tests/Feature/FormBuilder/ValidationRules/ValidationRulesResourceParityTest.php b/api/tests/Feature/FormBuilder/ValidationRules/ValidationRulesResourceParityTest.php new file mode 100644 index 00000000..73b2d143 --- /dev/null +++ b/api/tests/Feature/FormBuilder/ValidationRules/ValidationRulesResourceParityTest.php @@ -0,0 +1,75 @@ +create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TEXT->value, + ]); + FormFieldValidationRule::factory()->forField($field) + ->ofType(FormFieldValidationRuleType::MinLength, ['value' => 3])->create(); + FormFieldValidationRule::factory()->forField($field) + ->ofType(FormFieldValidationRuleType::MaxLength, ['value' => 40])->create(); + + $rendered = (new FormFieldResource($field->fresh()))->toArray(request()); + + $this->assertSame([ + 'min_length' => 3, + 'max_length' => 40, + ], $rendered['validation_rules']); + } + + public function test_form_field_resource_emits_null_when_no_rules(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $rendered = (new FormFieldResource($field->fresh()))->toArray(request()); + + $this->assertNull($rendered['validation_rules']); + } + + public function test_form_field_library_resource_emits_canonical_shape(): void + { + $org = Organisation::factory()->create(); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + FormFieldValidationRule::factory()->forLibrary($library) + ->ofType(FormFieldValidationRuleType::AllowedMimeTypes, ['mime_types' => ['image/png', 'image/jpeg']])->create(); + + $rendered = (new FormFieldLibraryResource($library->fresh()))->toArray(request()); + + $this->assertSame([ + 'allowed_mime_types' => ['image/png', 'image/jpeg'], + ], $rendered['validation_rules']); + } +}