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