feat(form-builder): FormFieldValidationRuleService + legacy backfill + snapshot + library row-copy

This commit is contained in:
2026-04-24 22:12:08 +02:00
parent fedaed1b32
commit 800b1b6c01
16 changed files with 1430 additions and 18 deletions

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\ValidationRules;
use App\Enums\FormBuilder\FormFieldValidationRuleType;
use App\Exceptions\FormBuilder\UnknownValidationRuleTypeException;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormFieldLibrary;
use App\Models\FormBuilder\FormFieldValidationRule;
use App\Models\FormBuilder\FormSchema;
use App\Models\Organisation;
use App\Services\FormBuilder\FormFieldValidationRuleService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
final class FormFieldValidationRuleServiceTest extends TestCase
{
use RefreshDatabase;
private FormFieldValidationRuleService $service;
protected function setUp(): void
{
parent::setUp();
$this->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]);
}
}