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

@@ -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);
}
}