refactor(form-builder): strict validator on save; strip rules.unique fallback

This commit is contained in:
2026-04-24 22:26:44 +02:00
parent 800b1b6c01
commit 64ec4bcc5c
12 changed files with 469 additions and 29 deletions

View File

@@ -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<string, mixed> $data
* @return list<array<string, mixed>>|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<array<string, mixed>> $raw */
return array_values($raw);
}
/**
* @param array<string, mixed> $data
* @return array{target_entity:string,target_attribute:string,mode:string,sync_direction?:?string}|null

View File

@@ -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<int, \Closure>
*/
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());
}
},
];
}
}

View File

@@ -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<int, \Closure>
*/
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());
}
},
];
}
}

View File

@@ -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<int, \Closure>
*/
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());
}
},
];
}
}

View File

@@ -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<int, \Closure>
*/
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());
}
},
];
}
}

View File

@@ -27,6 +27,10 @@ use App\Models\FormBuilder\FormSchema;
*/
final class FormFieldRuleBuilder
{
public function __construct(
private readonly FormFieldValidationRuleService $validationRuleService,
) {}
/**
* @return array<string, array<int, string>>
*/
@@ -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<int, string>
*/
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'];

View File

@@ -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<string, mixed> $data
* @return list<array<string, mixed>>|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<array<string, mixed>> $raw */
return array_values($raw);
}
/**
* @param array<string, mixed> $data
* @return array{target_entity:string,target_attribute:string,mode:string,sync_direction?:?string}|null

View File

@@ -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<array<string, mixed>> $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';

View File

@@ -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<int, string>
*/
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()

View File

@@ -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,

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\ValidationRules;
use App\Enums\FormBuilder\FormFieldType;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormFieldValidationRule;
use App\Models\FormBuilder\FormSchema;
use App\Models\Organisation;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/**
* WS-5b commit 3 FormRequest level strict validation of the
* `validation_rules` array-of-specs shape. Unknown rule_type, bad
* parameter shape, unregistered callback key all return 422 BEFORE any
* write lands.
*/
final class FormFieldStrictValidationRulesRequestTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private User $admin;
private FormSchema $schema;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\ValidationRules;
use PHPUnit\Framework\TestCase;
/**
* WS-5b consolidation static guard that the legacy
* `validation_rules.unique` JSON fallback has been stripped from
* `FormValueService`. The is_unique column is the single source of truth.
*
* We assert the textual absence rather than a behavioural test: uniqueness
* enforcement is heavily coupled to the FormValueObserver typed-column
* write path, submission status, etc. The cheap + high-signal check is
* "the code no longer references `$rules['unique']`".
*/
final class UniqueJsonFallbackRemovedTest extends TestCase
{
public function test_form_value_service_no_longer_reads_rules_unique_key(): void
{
$source = (string) file_get_contents(
__DIR__.'/../../../../app/Services/FormBuilder/FormValueService.php',
);
$this->assertStringNotContainsString("\$rules['unique']", $source);
$this->assertStringNotContainsString('$rules["unique"]', $source);
}
}