From 64ec4bcc5cc30693a43079aae15c6554031a63d7 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 24 Apr 2026 22:26:44 +0200 Subject: [PATCH] refactor(form-builder): strict validator on save; strip rules.unique fallback --- .../FormFieldLibraryController.php | 41 +++++ .../StoreFormFieldLibraryRequest.php | 27 +++ .../V1/FormBuilder/StoreFormFieldRequest.php | 27 +++ .../UpdateFormFieldLibraryRequest.php | 30 +++ .../V1/FormBuilder/UpdateFormFieldRequest.php | 30 +++ .../FormBuilder/FormFieldRuleBuilder.php | 26 ++- .../Services/FormBuilder/FormFieldService.php | 42 +++++ .../FormFieldValidationRuleService.php | 19 +- .../Services/FormBuilder/FormValueService.php | 34 ++-- .../FormBuilder/PublicFormValidationTest.php | 21 ++- ...mFieldStrictValidationRulesRequestTest.php | 171 ++++++++++++++++++ .../UniqueJsonFallbackRemovedTest.php | 30 +++ 12 files changed, 469 insertions(+), 29 deletions(-) create mode 100644 api/tests/Feature/FormBuilder/ValidationRules/FormFieldStrictValidationRulesRequestTest.php create mode 100644 api/tests/Feature/FormBuilder/ValidationRules/UniqueJsonFallbackRemovedTest.php diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldLibraryController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldLibraryController.php index dbdb55e0..d157cb2a 100644 --- a/api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldLibraryController.php +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldLibraryController.php @@ -11,6 +11,7 @@ use App\Http\Resources\FormBuilder\FormFieldLibraryResource; use App\Models\FormBuilder\FormFieldLibrary; use App\Models\Organisation; use App\Services\FormBuilder\FormFieldBindingService; +use App\Services\FormBuilder\FormFieldValidationRuleService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Support\Facades\Gate; @@ -20,6 +21,7 @@ final class FormFieldLibraryController extends Controller { public function __construct( private readonly FormFieldBindingService $bindingService, + private readonly FormFieldValidationRuleService $validationRuleService, ) {} public function index(Organisation $organisation): AnonymousResourceCollection @@ -48,6 +50,7 @@ final class FormFieldLibraryController extends Controller $data = $request->validated(); $bindingSpec = $this->extractBindingSpec($data); + $validationRuleSpecs = $this->extractValidationRuleSpecs($data); $data['organisation_id'] = $organisation->id; $data['is_system'] = false; $data['is_active'] ??= true; @@ -60,6 +63,10 @@ final class FormFieldLibraryController extends Controller $this->bindingService->replaceBindings($library, [$bindingSpec]); } + if ($validationRuleSpecs !== null) { + $this->validationRuleService->replaceRules($library, $validationRuleSpecs); + } + return $this->created(new FormFieldLibraryResource($library)); } @@ -72,6 +79,9 @@ final class FormFieldLibraryController extends Controller $bindingProvided = array_key_exists('default_binding', $data); $bindingSpec = $bindingProvided ? $this->extractBindingSpec($data) : null; + $validationRulesProvided = array_key_exists('validation_rules', $data); + $validationRuleSpecs = $validationRulesProvided ? $this->extractValidationRuleSpecs($data) : null; + $fieldLibrary->fill($data); $fieldLibrary->save(); @@ -82,9 +92,40 @@ final class FormFieldLibraryController extends Controller ); } + if ($validationRulesProvided) { + $this->validationRuleService->replaceRules( + $fieldLibrary, + $validationRuleSpecs ?? [], + ); + } + return $this->success(new FormFieldLibraryResource($fieldLibrary)); } + /** + * Extract validation rule specs from the request data array and return + * them for the service-layer writer. The JSON column is no longer + * written (WS-5b commit 3) — writes go through + * `FormFieldValidationRuleService::replaceRules` after save. + * + * @param array $data + * @return list>|null + */ + private function extractValidationRuleSpecs(array &$data): ?array + { + if (! array_key_exists('validation_rules', $data)) { + return null; + } + $raw = $data['validation_rules']; + unset($data['validation_rules']); + if (! is_array($raw)) { + return []; + } + + /** @var list> $raw */ + return array_values($raw); + } + /** * @param array $data * @return array{target_entity:string,target_attribute:string,mode:string,sync_direction?:?string}|null diff --git a/api/app/Http/Requests/Api/V1/FormBuilder/StoreFormFieldLibraryRequest.php b/api/app/Http/Requests/Api/V1/FormBuilder/StoreFormFieldLibraryRequest.php index 134495f6..142475aa 100644 --- a/api/app/Http/Requests/Api/V1/FormBuilder/StoreFormFieldLibraryRequest.php +++ b/api/app/Http/Requests/Api/V1/FormBuilder/StoreFormFieldLibraryRequest.php @@ -5,6 +5,9 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1\FormBuilder; use App\Enums\FormBuilder\FormFieldType; +use App\Exceptions\FormBuilder\UnknownValidationRuleTypeException; +use App\Services\FormBuilder\FormFieldValidationRuleService; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -33,6 +36,10 @@ final class StoreFormFieldLibraryRequest extends FormRequest 'help_text' => ['nullable', 'string'], 'options' => ['nullable', 'array'], 'validation_rules' => ['nullable', 'array'], + 'validation_rules.*' => ['array'], + 'validation_rules.*.rule_type' => ['required', 'string', 'max:40'], + 'validation_rules.*.parameters' => ['nullable', 'array'], + 'validation_rules.*.error_message_key' => ['nullable', 'string', 'max:100'], 'default_is_required' => ['boolean'], 'default_is_filterable' => ['boolean'], 'default_binding' => ['nullable', 'array'], @@ -43,4 +50,24 @@ final class StoreFormFieldLibraryRequest extends FormRequest 'is_system' => ['prohibited'], ]; } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + $specs = $this->input('validation_rules'); + if (! is_array($specs) || $specs === []) { + return; + } + try { + app(FormFieldValidationRuleService::class)->assertSpecsValid($specs); + } catch (UnknownValidationRuleTypeException $e) { + $validator->errors()->add('validation_rules', $e->getMessage()); + } + }, + ]; + } } diff --git a/api/app/Http/Requests/Api/V1/FormBuilder/StoreFormFieldRequest.php b/api/app/Http/Requests/Api/V1/FormBuilder/StoreFormFieldRequest.php index 5c55e02c..48286a84 100644 --- a/api/app/Http/Requests/Api/V1/FormBuilder/StoreFormFieldRequest.php +++ b/api/app/Http/Requests/Api/V1/FormBuilder/StoreFormFieldRequest.php @@ -7,7 +7,10 @@ namespace App\Http\Requests\Api\V1\FormBuilder; use App\Enums\FormBuilder\FormFieldDisplayWidth; use App\Enums\FormBuilder\FormFieldType; use App\Enums\FormBuilder\FormValueStorageHint; +use App\Exceptions\FormBuilder\UnknownValidationRuleTypeException; use App\Models\FormBuilder\FormSchema; +use App\Services\FormBuilder\FormFieldValidationRuleService; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -47,6 +50,10 @@ final class StoreFormFieldRequest extends FormRequest ], 'options' => ['nullable', 'array'], 'validation_rules' => ['nullable', 'array'], + 'validation_rules.*' => ['array'], + 'validation_rules.*.rule_type' => ['required', 'string', 'max:40'], + 'validation_rules.*.parameters' => ['nullable', 'array'], + 'validation_rules.*.error_message_key' => ['nullable', 'string', 'max:100'], 'is_required' => ['boolean'], 'is_filterable' => ['boolean'], 'is_portal_visible' => ['boolean'], @@ -64,4 +71,24 @@ final class StoreFormFieldRequest extends FormRequest 'form_schema_id' => ['prohibited'], ]; } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + $specs = $this->input('validation_rules'); + if (! is_array($specs) || $specs === []) { + return; + } + try { + app(FormFieldValidationRuleService::class)->assertSpecsValid($specs); + } catch (UnknownValidationRuleTypeException $e) { + $validator->errors()->add('validation_rules', $e->getMessage()); + } + }, + ]; + } } diff --git a/api/app/Http/Requests/Api/V1/FormBuilder/UpdateFormFieldLibraryRequest.php b/api/app/Http/Requests/Api/V1/FormBuilder/UpdateFormFieldLibraryRequest.php index d061e996..bfeaa434 100644 --- a/api/app/Http/Requests/Api/V1/FormBuilder/UpdateFormFieldLibraryRequest.php +++ b/api/app/Http/Requests/Api/V1/FormBuilder/UpdateFormFieldLibraryRequest.php @@ -5,6 +5,9 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1\FormBuilder; use App\Enums\FormBuilder\FormFieldType; +use App\Exceptions\FormBuilder\UnknownValidationRuleTypeException; +use App\Services\FormBuilder\FormFieldValidationRuleService; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -33,6 +36,10 @@ final class UpdateFormFieldLibraryRequest extends FormRequest 'help_text' => ['sometimes', 'nullable', 'string'], 'options' => ['sometimes', 'nullable', 'array'], 'validation_rules' => ['sometimes', 'nullable', 'array'], + 'validation_rules.*' => ['array'], + 'validation_rules.*.rule_type' => ['required', 'string', 'max:40'], + 'validation_rules.*.parameters' => ['nullable', 'array'], + 'validation_rules.*.error_message_key' => ['nullable', 'string', 'max:100'], 'default_is_required' => ['sometimes', 'boolean'], 'default_is_filterable' => ['sometimes', 'boolean'], 'default_binding' => ['sometimes', 'nullable', 'array'], @@ -43,4 +50,27 @@ final class UpdateFormFieldLibraryRequest extends FormRequest 'is_system' => ['prohibited'], ]; } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + if (! $this->has('validation_rules')) { + return; + } + $specs = $this->input('validation_rules'); + if (! is_array($specs) || $specs === []) { + return; + } + try { + app(FormFieldValidationRuleService::class)->assertSpecsValid($specs); + } catch (UnknownValidationRuleTypeException $e) { + $validator->errors()->add('validation_rules', $e->getMessage()); + } + }, + ]; + } } diff --git a/api/app/Http/Requests/Api/V1/FormBuilder/UpdateFormFieldRequest.php b/api/app/Http/Requests/Api/V1/FormBuilder/UpdateFormFieldRequest.php index 00305dce..dbba642b 100644 --- a/api/app/Http/Requests/Api/V1/FormBuilder/UpdateFormFieldRequest.php +++ b/api/app/Http/Requests/Api/V1/FormBuilder/UpdateFormFieldRequest.php @@ -7,7 +7,10 @@ namespace App\Http\Requests\Api\V1\FormBuilder; use App\Enums\FormBuilder\FormFieldDisplayWidth; use App\Enums\FormBuilder\FormFieldType; use App\Enums\FormBuilder\FormValueStorageHint; +use App\Exceptions\FormBuilder\UnknownValidationRuleTypeException; use App\Models\FormBuilder\FormSchema; +use App\Services\FormBuilder\FormFieldValidationRuleService; +use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -43,6 +46,10 @@ final class UpdateFormFieldRequest extends FormRequest ], 'options' => ['sometimes', 'nullable', 'array'], 'validation_rules' => ['sometimes', 'nullable', 'array'], + 'validation_rules.*' => ['array'], + 'validation_rules.*.rule_type' => ['required', 'string', 'max:40'], + 'validation_rules.*.parameters' => ['nullable', 'array'], + 'validation_rules.*.error_message_key' => ['nullable', 'string', 'max:100'], 'is_required' => ['sometimes', 'boolean'], 'is_filterable' => ['sometimes', 'boolean'], 'is_portal_visible' => ['sometimes', 'boolean'], @@ -61,4 +68,27 @@ final class UpdateFormFieldRequest extends FormRequest 'form_schema_id' => ['prohibited'], ]; } + + /** + * @return array + */ + public function after(): array + { + return [ + function (Validator $validator): void { + if (! $this->has('validation_rules')) { + return; + } + $specs = $this->input('validation_rules'); + if (! is_array($specs) || $specs === []) { + return; + } + try { + app(FormFieldValidationRuleService::class)->assertSpecsValid($specs); + } catch (UnknownValidationRuleTypeException $e) { + $validator->errors()->add('validation_rules', $e->getMessage()); + } + }, + ]; + } } diff --git a/api/app/Services/FormBuilder/FormFieldRuleBuilder.php b/api/app/Services/FormBuilder/FormFieldRuleBuilder.php index eb37749d..5d33074f 100644 --- a/api/app/Services/FormBuilder/FormFieldRuleBuilder.php +++ b/api/app/Services/FormBuilder/FormFieldRuleBuilder.php @@ -27,6 +27,10 @@ use App\Models\FormBuilder\FormSchema; */ final class FormFieldRuleBuilder { + public function __construct( + private readonly FormFieldValidationRuleService $validationRuleService, + ) {} + /** * @return array> */ @@ -180,26 +184,36 @@ final class FormFieldRuleBuilder } /** - * Shortcuts picked up from form_fields.validation_rules JSON. + * Shortcuts picked up from the relational validation-rules table. * Service-layer FormValueService does the deeper min/max/regex/unique * enforcement — these are quick boundary checks surfaced at the * Request layer when cheap. * + * Laravel's `min:N` / `max:N` already do the right thing for both + * numeric inputs (value comparison) and strings (length check), so + * `min_length`/`min_value` both emit the same `min:N` rule. + * * @return array */ private function validationRuleShortcuts(FormField $field): array { $rules = []; - $v = $field->validation_rules ?? null; + $v = $this->validationRuleService->toJsonShape($field->validationRules); if (! is_array($v)) { return $rules; } - if (isset($v['min']) && is_numeric($v['min'])) { - $rules[] = 'min:'.(string) $v['min']; + if (isset($v['min_value']) && is_numeric($v['min_value'])) { + $rules[] = 'min:'.(string) $v['min_value']; } - if (isset($v['max']) && is_numeric($v['max'])) { - $rules[] = 'max:'.(string) $v['max']; + if (isset($v['max_value']) && is_numeric($v['max_value'])) { + $rules[] = 'max:'.(string) $v['max_value']; + } + if (isset($v['min_length']) && is_numeric($v['min_length'])) { + $rules[] = 'min:'.(string) $v['min_length']; + } + if (isset($v['max_length']) && is_numeric($v['max_length'])) { + $rules[] = 'max:'.(string) $v['max_length']; } if (isset($v['regex']) && is_string($v['regex'])) { $rules[] = 'regex:'.$v['regex']; diff --git a/api/app/Services/FormBuilder/FormFieldService.php b/api/app/Services/FormBuilder/FormFieldService.php index c8d918b3..f80f0674 100644 --- a/api/app/Services/FormBuilder/FormFieldService.php +++ b/api/app/Services/FormBuilder/FormFieldService.php @@ -38,6 +38,7 @@ final class FormFieldService $data['sort_order'] ??= $this->nextSortOrder($schema); $bindingSpec = $this->extractBindingSpec($data); + $validationRuleSpecs = $this->extractValidationRuleSpecs($data); $this->assertNoConditionalCycle($schema, null, $data['conditional_logic'] ?? null, $data['slug'] ?? null); @@ -48,6 +49,10 @@ final class FormFieldService $this->bindingService->replaceBindings($field, [$bindingSpec]); } + if ($validationRuleSpecs !== null) { + $this->validationRuleService->replaceRules($field, $validationRuleSpecs); + } + $this->schemaService->bumpVersion($schema); $field->logFieldChange('field.created'); @@ -67,6 +72,9 @@ final class FormFieldService $rawBinding = $bindingProvided ? $data['binding'] : null; $bindingSpec = $bindingProvided ? $this->extractBindingSpec($data) : null; + $validationRulesProvided = array_key_exists('validation_rules', $data); + $validationRuleSpecs = $validationRulesProvided ? $this->extractValidationRuleSpecs($data) : null; + $currentBindingShape = $this->bindingService->toJsonShape($field->bindings()->first()); if ($bindingProvided && $this->bindingChanged($currentBindingShape, $rawBinding)) { @@ -91,6 +99,10 @@ final class FormFieldService $this->bindingService->replaceBindings($field, $bindingSpec === null ? [] : [$bindingSpec]); } + if ($validationRulesProvided) { + $this->validationRuleService->replaceRules($field, $validationRuleSpecs ?? []); + } + $this->schemaService->bumpVersion($schema); $field->logFieldChange('field.updated', [ @@ -110,6 +122,36 @@ final class FormFieldService return $field->refresh(); } + /** + * Extract the `validation_rules` key from the request data array and + * return it as the service-layer spec list. The JSON column is no + * longer written (WS-5b commit 3) — writes go through + * `FormFieldValidationRuleService::replaceRules` after the FormField + * row is created/updated. + * + * Returns `null` when the key was absent (no change requested), or an + * empty list when the caller explicitly cleared rules. Callers + * distinguish via `array_key_exists('validation_rules', $data)` + * BEFORE invoking this helper. + * + * @param array $data + * @return list>|null + */ + private function extractValidationRuleSpecs(array &$data): ?array + { + if (! array_key_exists('validation_rules', $data)) { + return null; + } + $raw = $data['validation_rules']; + unset($data['validation_rules']); + if (! is_array($raw)) { + return []; + } + + /** @var list> $raw */ + return array_values($raw); + } + /** * @param array $data * @return array{target_entity:string,target_attribute:string,mode:string,sync_direction?:?string}|null diff --git a/api/app/Services/FormBuilder/FormFieldValidationRuleService.php b/api/app/Services/FormBuilder/FormFieldValidationRuleService.php index fc68576e..305e3faf 100644 --- a/api/app/Services/FormBuilder/FormFieldValidationRuleService.php +++ b/api/app/Services/FormBuilder/FormFieldValidationRuleService.php @@ -53,9 +53,7 @@ final class FormFieldValidationRuleService */ public function replaceRules(FormField|FormFieldLibrary $owner, array $specs): void { - foreach ($specs as $spec) { - $this->assertSpecValid($spec); - } + $this->assertSpecsValid($specs); $ownerType = $this->ownerTypeFor($owner); @@ -182,6 +180,21 @@ final class FormFieldValidationRuleService }; } + /** + * Public wrapper around `assertSpecValid` — iterates a caller-supplied + * list and throws the first `UnknownValidationRuleTypeException`. + * Used by FormRequests (WS-5b commit 3 strict validator on save) to + * reject bad specs at the HTTP boundary before any write lands. + * + * @param list> $specs + */ + public function assertSpecsValid(array $specs): void + { + foreach ($specs as $spec) { + $this->assertSpecValid($spec); + } + } + private function ownerTypeFor(FormField|FormFieldLibrary $owner): string { return $owner instanceof FormField ? 'form_field' : 'form_field_library'; diff --git a/api/app/Services/FormBuilder/FormValueService.php b/api/app/Services/FormBuilder/FormValueService.php index 9dbb80c7..0622e42c 100644 --- a/api/app/Services/FormBuilder/FormValueService.php +++ b/api/app/Services/FormBuilder/FormValueService.php @@ -26,6 +26,7 @@ final class FormValueService { public function __construct( private readonly FieldAccessService $fieldAccess, + private readonly FormFieldValidationRuleService $validationRuleService, ) {} /** @@ -76,17 +77,23 @@ final class FormValueService } /** - * Backstop enforcement of form_fields.validation_rules JSON per - * S2c D8. The FormFieldRuleBuilder already surfaces min/max/regex - * shortcuts at the request layer; this is where the deeper checks - * (is_unique, validation_rules.unique) live. + * Backstop enforcement of per-field validation rules. Rules are sourced + * from the relational `form_field_validation_rules` table via + * `FormFieldValidationRuleService::toJsonShape()`, which returns the + * canonical flat bag: `min_value`/`max_value` for numeric fields, + * `min_length`/`max_length` for strings, `regex` for pattern checks. + * The pre-WS-5b ambiguous `min`/`max` keys no longer exist. + * + * Uniqueness: `is_unique` column is the single source of truth (WS-5b + * consolidation — the legacy `validation_rules.unique` JSON fallback + * was removed in commit 3). * * @return array */ private function validateAgainstFieldRules(FormField $field, mixed $raw, FormSubmission $submission): array { $errors = []; - $rules = is_array($field->validation_rules) ? $field->validation_rules : []; + $rules = $this->validationRuleService->toJsonShape($field->validationRules) ?? []; if ($field->field_type === FormFieldType::SECTION_PRIORITY->value) { $shapeErrors = $this->validateSectionPriorityShape($raw, $submission); @@ -99,19 +106,24 @@ final class FormValueService return $errors; } - if (isset($rules['min']) && is_numeric($rules['min']) && is_numeric($raw) && (float) $raw < (float) $rules['min']) { - $errors[] = sprintf('Minimum is %s.', (string) $rules['min']); + if (isset($rules['min_value']) && is_numeric($rules['min_value']) && is_numeric($raw) && (float) $raw < (float) $rules['min_value']) { + $errors[] = sprintf('Minimum is %s.', (string) $rules['min_value']); } - if (isset($rules['max']) && is_numeric($rules['max']) && is_numeric($raw) && (float) $raw > (float) $rules['max']) { - $errors[] = sprintf('Maximum is %s.', (string) $rules['max']); + if (isset($rules['max_value']) && is_numeric($rules['max_value']) && is_numeric($raw) && (float) $raw > (float) $rules['max_value']) { + $errors[] = sprintf('Maximum is %s.', (string) $rules['max_value']); + } + if (isset($rules['min_length']) && is_numeric($rules['min_length']) && is_string($raw) && mb_strlen($raw) < (int) $rules['min_length']) { + $errors[] = sprintf('Minimaal %d tekens.', (int) $rules['min_length']); + } + if (isset($rules['max_length']) && is_numeric($rules['max_length']) && is_string($raw) && mb_strlen($raw) > (int) $rules['max_length']) { + $errors[] = sprintf('Maximaal %d tekens.', (int) $rules['max_length']); } if (isset($rules['regex']) && is_string($rules['regex']) && is_string($raw) && @preg_match($rules['regex'], $raw) !== 1) { $errors[] = 'Value does not match the expected format.'; } - $unique = (bool) $field->is_unique || (bool) ($rules['unique'] ?? false); - if ($unique) { + if ($field->is_unique) { $scalar = is_scalar($raw) ? (string) $raw : null; if ($scalar !== null) { $exists = \App\Models\FormBuilder\FormValue::query() diff --git a/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormValidationTest.php b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormValidationTest.php index ce231468..c4a52c86 100644 --- a/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormValidationTest.php +++ b/api/tests/Feature/Api/V1/Public/FormBuilder/PublicFormValidationTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Feature\Api\V1\Public\FormBuilder; use App\Enums\FormBuilder\FormFieldType; +use App\Enums\FormBuilder\FormFieldValidationRuleType; use App\Enums\FormBuilder\FormPurpose; use App\Models\FormBuilder\FormField; use App\Models\FormBuilder\FormSchema; @@ -43,15 +44,17 @@ final class PublicFormValidationTest extends TestCase 'is_required' => true, 'is_portal_visible' => true, ]); - FormField::factory()->create([ - 'form_schema_id' => $this->schema->id, - 'field_type' => FormFieldType::NUMBER->value, - 'slug' => 'leeftijd', - 'label' => 'Leeftijd', - 'is_required' => false, - 'is_portal_visible' => true, - 'validation_rules' => ['min' => 16, 'max' => 99], - ]); + FormField::factory() + ->withValidationRule(FormFieldValidationRuleType::MinValue, ['value' => 16]) + ->withValidationRule(FormFieldValidationRuleType::MaxValue, ['value' => 99]) + ->create([ + 'form_schema_id' => $this->schema->id, + 'field_type' => FormFieldType::NUMBER->value, + 'slug' => 'leeftijd', + 'label' => 'Leeftijd', + 'is_required' => false, + 'is_portal_visible' => true, + ]); FormField::factory()->create([ 'form_schema_id' => $this->schema->id, 'field_type' => FormFieldType::SELECT->value, diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldStrictValidationRulesRequestTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldStrictValidationRulesRequestTest.php new file mode 100644 index 00000000..cacf62c7 --- /dev/null +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldStrictValidationRulesRequestTest.php @@ -0,0 +1,171 @@ +seed(RoleSeeder::class); + $this->org = Organisation::factory()->create(); + $this->admin = User::factory()->create(); + $this->org->users()->attach($this->admin, ['role' => 'org_admin']); + $this->schema = FormSchema::factory()->create(['organisation_id' => $this->org->id]); + } + + public function test_store_rejects_unregistered_rule_type(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/fields", + [ + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'voornaam', + 'label' => 'Voornaam', + 'validation_rules' => [ + ['rule_type' => 'not_a_real_rule', 'parameters' => []], + ], + ], + ); + + $response->assertStatus(422); + $this->assertArrayHasKey('validation_rules', $response->json('errors') ?? []); + } + + public function test_store_rejects_bad_parameter_shape(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/fields", + [ + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'voornaam', + 'label' => 'Voornaam', + 'validation_rules' => [ + ['rule_type' => 'min_length', 'parameters' => ['value' => 'not-an-int']], + ], + ], + ); + + $response->assertStatus(422); + } + + public function test_store_rejects_regex_missing_pattern(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/fields", + [ + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'postcode', + 'label' => 'Postcode', + 'validation_rules' => [ + ['rule_type' => 'regex', 'parameters' => []], + ], + ], + ); + + $response->assertStatus(422); + } + + public function test_store_rejects_unregistered_callback_key(): void + { + Config::set('form_builder.validation_callbacks', []); + Sanctum::actingAs($this->admin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/fields", + [ + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'kvk', + 'label' => 'KvK-nummer', + 'validation_rules' => [ + ['rule_type' => 'callback', 'parameters' => ['key' => 'not_registered']], + ], + ], + ); + + $response->assertStatus(422); + } + + public function test_store_accepts_valid_specs_and_persists_rows(): void + { + Sanctum::actingAs($this->admin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/fields", + [ + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'voornaam', + 'label' => 'Voornaam', + 'validation_rules' => [ + ['rule_type' => 'min_length', 'parameters' => ['value' => 2]], + ['rule_type' => 'max_length', 'parameters' => ['value' => 40]], + ], + ], + ); + + $response->assertCreated(); + $fieldId = $response->json('data.id'); + + $rules = FormFieldValidationRule::query() + ->where('owner_type', 'form_field') + ->where('owner_id', $fieldId) + ->pluck('rule_type') + ->map(static fn ($r) => $r instanceof \BackedEnum ? $r->value : (string) $r) + ->sort()->values()->all(); + $this->assertSame(['max_length', 'min_length'], $rules); + + // The JSON column is not written on the service path — stays null + // until commit 5 drops it. + $this->assertNull(FormField::query()->findOrFail($fieldId)->validation_rules); + } + + public function test_update_empty_array_clears_rules(): void + { + Sanctum::actingAs($this->admin); + $field = FormField::factory()->create(['form_schema_id' => $this->schema->id]); + FormFieldValidationRule::factory()->forField($field)->create(); + $this->assertCount(1, $field->fresh()->validationRules); + + $response = $this->putJson( + "/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/fields/{$field->id}", + ['validation_rules' => []], + ); + + $response->assertOk(); + $this->assertCount(0, $field->fresh()->validationRules); + } +} diff --git a/api/tests/Feature/FormBuilder/ValidationRules/UniqueJsonFallbackRemovedTest.php b/api/tests/Feature/FormBuilder/ValidationRules/UniqueJsonFallbackRemovedTest.php new file mode 100644 index 00000000..f5a343d5 --- /dev/null +++ b/api/tests/Feature/FormBuilder/ValidationRules/UniqueJsonFallbackRemovedTest.php @@ -0,0 +1,30 @@ +assertStringNotContainsString("\$rules['unique']", $source); + $this->assertStringNotContainsString('$rules["unique"]', $source); + } +}