diff --git a/api/app/Enums/FormBuilder/FormFieldValidationRuleType.php b/api/app/Enums/FormBuilder/FormFieldValidationRuleType.php new file mode 100644 index 00000000..d8e00d61 --- /dev/null +++ b/api/app/Enums/FormBuilder/FormFieldValidationRuleType.php @@ -0,0 +1,36 @@ +morphMany(FormFieldBinding::class, 'owner'); } + public function validationRules(): MorphMany + { + return $this->morphMany(FormFieldValidationRule::class, 'owner'); + } + /** * Nuanced activity log (ARCH §17.1; S1 Phase 4b). Callers choose which * events are worth logging — e.g. created/deleted/restored, field_type diff --git a/api/app/Models/FormBuilder/FormFieldLibrary.php b/api/app/Models/FormBuilder/FormFieldLibrary.php index a23e31c1..57fc46a8 100644 --- a/api/app/Models/FormBuilder/FormFieldLibrary.php +++ b/api/app/Models/FormBuilder/FormFieldLibrary.php @@ -69,4 +69,9 @@ final class FormFieldLibrary extends Model { return $this->morphMany(FormFieldBinding::class, 'owner'); } + + public function validationRules(): MorphMany + { + return $this->morphMany(FormFieldValidationRule::class, 'owner'); + } } diff --git a/api/app/Models/FormBuilder/FormFieldValidationRule.php b/api/app/Models/FormBuilder/FormFieldValidationRule.php new file mode 100644 index 00000000..56fc44a7 --- /dev/null +++ b/api/app/Models/FormBuilder/FormFieldValidationRule.php @@ -0,0 +1,55 @@ + */ + protected $casts = [ + 'rule_type' => FormFieldValidationRuleType::class, + 'parameters' => 'array', + ]; + + public function owner(): MorphTo + { + return $this->morphTo('owner', 'owner_type', 'owner_id'); + } +} diff --git a/api/app/Models/Scopes/FormFieldValidationRuleScope.php b/api/app/Models/Scopes/FormFieldValidationRuleScope.php new file mode 100644 index 00000000..61a66201 --- /dev/null +++ b/api/app/Models/Scopes/FormFieldValidationRuleScope.php @@ -0,0 +1,102 @@ +resolveOrganisationId(); + if ($orgId === null) { + return; + } + + $fieldIds = FormField::query() + ->withoutGlobalScope(OrganisationScope::class) + ->whereIn( + 'form_schema_id', + FormSchema::query() + ->withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $orgId) + ->select('id'), + ) + ->select('id'); + + $libraryIds = FormFieldLibrary::query() + ->withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $orgId) + ->select('id'); + + $table = $model->getTable(); + + $builder->where(function (Builder $outer) use ($table, $fieldIds, $libraryIds): void { + $outer->where(function (Builder $q) use ($table, $fieldIds): void { + $q->where("$table.owner_type", 'form_field') + ->whereIn("$table.owner_id", $fieldIds); + })->orWhere(function (Builder $q) use ($table, $libraryIds): void { + $q->where("$table.owner_type", 'form_field_library') + ->whereIn("$table.owner_id", $libraryIds); + }); + }); + } + + private function resolveOrganisationId(): ?string + { + if ($this->organisationId !== null) { + return $this->organisationId; + } + + $route = request()->route(); + if ($route === null) { + return null; + } + + $org = $route->parameter('organisation'); + + if ($org instanceof \App\Models\Organisation) { + return $org->id; + } + + if (is_string($org) && $org !== '') { + return $org; + } + + $event = $route->parameter('event'); + if ($event instanceof \App\Models\Event) { + return $event->organisation_id; + } + + return null; + } +} diff --git a/api/app/Observers/FormBuilder/FormFieldBindingsCascadeObserver.php b/api/app/Observers/FormBuilder/FormFieldBindingsCascadeObserver.php deleted file mode 100644 index c2a303a5..00000000 --- a/api/app/Observers/FormBuilder/FormFieldBindingsCascadeObserver.php +++ /dev/null @@ -1,29 +0,0 @@ -where('owner_type', $ownerType) - ->where('owner_id', $owner->getKey()) - ->delete(); - } -} diff --git a/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php b/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php new file mode 100644 index 00000000..67fcf9d7 --- /dev/null +++ b/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php @@ -0,0 +1,49 @@ +getKey(); + + DB::table('form_field_bindings') + ->where('owner_type', $ownerType) + ->where('owner_id', $ownerId) + ->delete(); + + if (Schema::hasTable('form_field_validation_rules')) { + DB::table('form_field_validation_rules') + ->where('owner_type', $ownerType) + ->where('owner_id', $ownerId) + ->delete(); + } + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 7e370846..43f219a0 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -48,7 +48,7 @@ use App\Models\VolunteerAvailability; use App\Events\FormBuilder\FormSubmissionSubmitted; use App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit; use App\Listeners\FormBuilder\TriggerPersonIdentityMatchOnFormSubmit; -use App\Observers\FormBuilder\FormFieldBindingsCascadeObserver; +use App\Observers\FormBuilder\FormFieldChildTablesCascadeObserver; use App\Observers\FormBuilder\FormSubmissionObserver; use App\Observers\FormBuilder\FormValueObserver; use App\Observers\PersonObserver; @@ -95,10 +95,12 @@ class AppServiceProvider extends ServiceProvider FormValue::observe(FormValueObserver::class); \App\Models\FormBuilder\FormSubmission::observe(FormSubmissionObserver::class); - // Cascade binding rows on owner delete (WS-5a). Bindings are physical - // state; deleted on soft-delete as well as hard-delete of the owner. - FormField::observe(FormFieldBindingsCascadeObserver::class); - FormFieldLibrary::observe(FormFieldBindingsCascadeObserver::class); + // Cascade binding / validation-rule / config rows on owner delete. + // Children are physical state; deleted on soft-delete as well as + // hard-delete of the owner (WS-5a bindings, WS-5b validation rules + // + configs). + FormField::observe(FormFieldChildTablesCascadeObserver::class); + FormFieldLibrary::observe(FormFieldChildTablesCascadeObserver::class); // ARCH §31.10 — FORM-02 TAG_PICKER sync listener. \Illuminate\Support\Facades\Event::listen( diff --git a/api/database/factories/FormBuilder/FormFieldFactory.php b/api/database/factories/FormBuilder/FormFieldFactory.php index 206dd4b4..caa01f7e 100644 --- a/api/database/factories/FormBuilder/FormFieldFactory.php +++ b/api/database/factories/FormBuilder/FormFieldFactory.php @@ -7,8 +7,10 @@ namespace Database\Factories\FormBuilder; use App\Enums\FormBuilder\FormFieldBindingMode; use App\Enums\FormBuilder\FormFieldDisplayWidth; use App\Enums\FormBuilder\FormFieldType; +use App\Enums\FormBuilder\FormFieldValidationRuleType; use App\Models\FormBuilder\FormField; use App\Models\FormBuilder\FormFieldBinding; +use App\Models\FormBuilder\FormFieldValidationRule; use App\Models\FormBuilder\FormSchema; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -100,4 +102,21 @@ final class FormFieldFactory extends Factory ->create(); }); } + + /** + * Attach a validation-rule row in `form_field_validation_rules` after + * the field is persisted. Replaces populating the legacy + * `validation_rules` JSON column — which WS-5b commit 5 drops. + * + * @param array $parameters + */ + public function withValidationRule(FormFieldValidationRuleType $type, array $parameters): static + { + return $this->afterCreating(function (FormField $field) use ($type, $parameters): void { + FormFieldValidationRule::factory() + ->forField($field) + ->ofType($type, $parameters) + ->create(); + }); + } } diff --git a/api/database/factories/FormBuilder/FormFieldLibraryFactory.php b/api/database/factories/FormBuilder/FormFieldLibraryFactory.php index b6da4b3e..edac1693 100644 --- a/api/database/factories/FormBuilder/FormFieldLibraryFactory.php +++ b/api/database/factories/FormBuilder/FormFieldLibraryFactory.php @@ -6,8 +6,10 @@ namespace Database\Factories\FormBuilder; use App\Enums\FormBuilder\FormFieldBindingMode; use App\Enums\FormBuilder\FormFieldType; +use App\Enums\FormBuilder\FormFieldValidationRuleType; use App\Models\FormBuilder\FormFieldBinding; use App\Models\FormBuilder\FormFieldLibrary; +use App\Models\FormBuilder\FormFieldValidationRule; use App\Models\Organisation; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -71,4 +73,21 @@ final class FormFieldLibraryFactory extends Factory ->create(); }); } + + /** + * Attach a validation-rule row in `form_field_validation_rules` after + * the library entry is persisted. Replaces populating the legacy + * `validation_rules` JSON column — which WS-5b commit 5 drops. + * + * @param array $parameters + */ + public function withValidationRule(FormFieldValidationRuleType $type, array $parameters): static + { + return $this->afterCreating(function (FormFieldLibrary $library) use ($type, $parameters): void { + FormFieldValidationRule::factory() + ->forLibrary($library) + ->ofType($type, $parameters) + ->create(); + }); + } } diff --git a/api/database/factories/FormBuilder/FormFieldValidationRuleFactory.php b/api/database/factories/FormBuilder/FormFieldValidationRuleFactory.php new file mode 100644 index 00000000..45145f3c --- /dev/null +++ b/api/database/factories/FormBuilder/FormFieldValidationRuleFactory.php @@ -0,0 +1,56 @@ + */ +final class FormFieldValidationRuleFactory extends Factory +{ + protected $model = FormFieldValidationRule::class; + + /** @return array */ + public function definition(): array + { + return [ + 'owner_type' => 'form_field', + 'owner_id' => FormField::factory(), + 'rule_type' => FormFieldValidationRuleType::MinLength->value, + 'parameters' => ['value' => 3], + 'error_message_key' => null, + ]; + } + + public function forField(FormField $field): static + { + return $this->state(fn () => [ + 'owner_type' => 'form_field', + 'owner_id' => $field->id, + ]); + } + + public function forLibrary(FormFieldLibrary $library): static + { + return $this->state(fn () => [ + 'owner_type' => 'form_field_library', + 'owner_id' => $library->id, + ]); + } + + /** + * @param array $parameters + */ + public function ofType(FormFieldValidationRuleType $type, array $parameters): static + { + return $this->state(fn () => [ + 'rule_type' => $type->value, + 'parameters' => $parameters, + ]); + } +} diff --git a/api/database/migrations/2026_04_25_110000_create_form_field_validation_rules_table.php b/api/database/migrations/2026_04_25_110000_create_form_field_validation_rules_table.php new file mode 100644 index 00000000..9df7b8f8 --- /dev/null +++ b/api/database/migrations/2026_04_25_110000_create_form_field_validation_rules_table.php @@ -0,0 +1,47 @@ +ulid('id')->primary(); + $table->string('owner_type', 40); + $table->ulid('owner_id'); + $table->string('rule_type', 40); + $table->json('parameters'); + $table->string('error_message_key', 100)->nullable(); + $table->timestamps(); + + $table->unique( + ['owner_type', 'owner_id', 'rule_type'], + 'ffvr_owner_rule_unique', + ); + $table->index('rule_type', 'ffvr_rule_idx'); + $table->index(['owner_type', 'owner_id'], 'ffvr_owner_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('form_field_validation_rules'); + } +}; diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index 865a2e45..5615dffd 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -33,8 +33,9 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_forward_migrations_backfill_rows_from_both_json_sources(): void { - // Roll back: drop_binding_json_columns → create_form_field_bindings. - $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + // Roll back: create_form_field_validation_rules_table (WS-5b commit 1) + // → drop_binding_json_columns → create_form_field_bindings. + $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding')); @@ -95,7 +96,9 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_rollback_reconstructs_json_and_drops_table(): void { - $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + // Walk back: validation-rules table (WS-5b commit 1) → + // drop_binding_json_columns → create_form_field_bindings. + $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); [$fieldAId, , ] = $this->seedFieldsWithBindingJson(); [$libAId, ] = $this->seedLibraryWithBindingJson(); @@ -105,6 +108,12 @@ final class FormFieldBindingMigrationTest extends TestCase $this->assertFalse(Schema::hasColumn('form_fields', 'binding')); $this->assertSame(5, DB::table('form_field_bindings')->count()); + // Step back over WS-5b validation-rules table → irrelevant to the + // binding contract, but restores the pre-WS-5b state. + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->assertFalse(Schema::hasTable('form_field_validation_rules')); + $this->assertTrue(Schema::hasTable('form_field_bindings')); + // Step back over drop_binding_json_columns → columns reappear empty. $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleCascadeTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleCascadeTest.php new file mode 100644 index 00000000..3a0ad163 --- /dev/null +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleCascadeTest.php @@ -0,0 +1,124 @@ +create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + FormFieldValidationRule::factory()->forField($field)->create(); + FormFieldValidationRule::factory() + ->forField($field) + ->ofType(\App\Enums\FormBuilder\FormFieldValidationRuleType::MaxLength, ['value' => 10]) + ->create(); + + $this->assertSame(2, FormFieldValidationRule::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->where('owner_id', $field->id) + ->count(), + ); + + $field->delete(); // soft delete on FormField + + $this->assertSame(0, FormFieldValidationRule::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->where('owner_id', $field->id) + ->count(), + ); + } + + public function test_delete_of_library_entry_cascades_validation_rules(): void + { + $org = Organisation::factory()->create(); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + + FormFieldValidationRule::factory()->forLibrary($library)->create(); + + $this->assertSame(1, FormFieldValidationRule::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field_library') + ->where('owner_id', $library->id) + ->count(), + ); + + $library->delete(); + + $this->assertSame(0, FormFieldValidationRule::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field_library') + ->where('owner_id', $library->id) + ->count(), + ); + } + + public function test_deleting_one_field_does_not_cascade_others(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $a = FormField::factory()->create(['form_schema_id' => $schema->id]); + $b = FormField::factory()->create(['form_schema_id' => $schema->id]); + + FormFieldValidationRule::factory()->forField($a)->create(); + FormFieldValidationRule::factory()->forField($b)->create(); + + $a->delete(); + + $this->assertSame(1, FormFieldValidationRule::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->where('owner_id', $b->id) + ->count(), + ); + } + + public function test_cascade_observer_also_cleans_up_bindings_on_same_owner(): void + { + // Regression guard after renaming the observer: the combined + // observer must still clean up binding rows (WS-5a responsibility) + // not only validation rules (WS-5b addition). + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create(); + FormFieldValidationRule::factory()->forField($field)->create(); + + $field->delete(); + + $this->assertSame(0, FormFieldBinding::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->where('owner_id', $field->id) + ->count(), + ); + $this->assertSame(0, FormFieldValidationRule::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->where('owner_id', $field->id) + ->count(), + ); + } +} diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleRelationTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleRelationTest.php new file mode 100644 index 00000000..e70ea232 --- /dev/null +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleRelationTest.php @@ -0,0 +1,111 @@ +create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + FormFieldValidationRule::factory() + ->forField($field) + ->ofType(FormFieldValidationRuleType::MinLength, ['value' => 2]) + ->create(); + FormFieldValidationRule::factory() + ->forField($field) + ->ofType(FormFieldValidationRuleType::MaxLength, ['value' => 40]) + ->create(); + + $rules = $field->fresh()->validationRules; + $this->assertCount(2, $rules); + $types = $rules->pluck('rule_type.value')->sort()->values()->all(); + $this->assertSame(['max_length', 'min_length'], $types); + } + + public function test_library_morph_many_validation_rules_loads_all(): void + { + $org = Organisation::factory()->create(); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + + FormFieldValidationRule::factory() + ->forLibrary($library) + ->ofType(FormFieldValidationRuleType::Regex, ['pattern' => '/^[0-9]+$/']) + ->create(); + + $rules = $library->fresh()->validationRules; + $this->assertCount(1, $rules); + $this->assertSame(FormFieldValidationRuleType::Regex, $rules->first()->rule_type); + $this->assertSame(['pattern' => '/^[0-9]+$/'], $rules->first()->parameters); + } + + public function test_owner_morphto_returns_correct_concrete_model(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + + $fieldRule = FormFieldValidationRule::factory()->forField($field)->create(); + $libraryRule = FormFieldValidationRule::factory()->forLibrary($library)->create(); + + $this->assertInstanceOf(FormField::class, $fieldRule->fresh()->owner); + $this->assertSame($field->id, $fieldRule->fresh()->owner->id); + + $this->assertInstanceOf(FormFieldLibrary::class, $libraryRule->fresh()->owner); + $this->assertSame($library->id, $libraryRule->fresh()->owner->id); + } + + public function test_morph_aliases_form_field_and_form_field_library_are_registered(): void + { + // Morph-map alignment guard — both aliases need to resolve for the + // polymorphic `owner` relation on the validation-rule rows. Reuses + // the aliases WS-5a registered for bindings; this test protects + // against an accidental rename in AppServiceProvider. + $morphMap = Relation::morphMap(); + $this->assertArrayHasKey('form_field', $morphMap); + $this->assertSame(FormField::class, $morphMap['form_field']); + $this->assertArrayHasKey('form_field_library', $morphMap); + $this->assertSame(FormFieldLibrary::class, $morphMap['form_field_library']); + } + + public function test_parameters_roundtrip_through_json_cast(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $rule = FormFieldValidationRule::factory() + ->forField($field) + ->ofType(FormFieldValidationRuleType::AllowedMimeTypes, [ + 'mime_types' => ['image/jpeg', 'image/png'], + ]) + ->create(); + + $fresh = $rule->fresh(); + $this->assertSame( + ['mime_types' => ['image/jpeg', 'image/png']], + $fresh->parameters, + ); + $this->assertSame( + FormFieldValidationRuleType::AllowedMimeTypes, + $fresh->rule_type, + ); + } +} diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleScopeTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleScopeTest.php new file mode 100644 index 00000000..93e309e9 --- /dev/null +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleScopeTest.php @@ -0,0 +1,80 @@ +seedOrgWithRules(); + [$orgB, $fieldB, $libraryB] = $this->seedOrgWithRules(); + + $this->withOrgRoute($orgA); + $ownerIdsA = FormFieldValidationRule::query()->pluck('owner_id')->sort()->values()->all(); + $expectedA = collect([$fieldA->id, $libraryA->id])->sort()->values()->all(); + $this->assertSame($expectedA, $ownerIdsA); + + $this->withOrgRoute($orgB); + $ownerIdsB = FormFieldValidationRule::query()->pluck('owner_id')->sort()->values()->all(); + $expectedB = collect([$fieldB->id, $libraryB->id])->sort()->values()->all(); + $this->assertSame($expectedB, $ownerIdsB); + } + + public function test_without_global_scope_exposes_cross_org(): void + { + [$orgA, , ] = $this->seedOrgWithRules(); + $this->seedOrgWithRules(); + + $this->withOrgRoute($orgA); + + $this->assertSame( + 4, + FormFieldValidationRule::query() + ->withoutGlobalScope(FormFieldValidationRuleScope::class) + ->count(), + ); + $this->assertSame(2, FormFieldValidationRule::query()->count()); + } + + /** @return array{0:Organisation,1:FormField,2:FormFieldLibrary} */ + private function seedOrgWithRules(): array + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + + FormFieldValidationRule::factory()->forField($field)->create(); + FormFieldValidationRule::factory()->forLibrary($library)->create(); + + return [$org, $field, $library]; + } + + private function withOrgRoute(Organisation $org): void + { + $route = new Route(['GET'], '/_test', static fn () => null); + $route->bind(request()); + $route->setParameter('organisation', $org); + request()->setRouteResolver(static fn () => $route); + } +} diff --git a/api/tests/Unit/Enums/FormBuilder/FormFieldValidationRuleTypeEnumTest.php b/api/tests/Unit/Enums/FormBuilder/FormFieldValidationRuleTypeEnumTest.php new file mode 100644 index 00000000..3dab170a --- /dev/null +++ b/api/tests/Unit/Enums/FormBuilder/FormFieldValidationRuleTypeEnumTest.php @@ -0,0 +1,71 @@ + $c->value, + FormFieldValidationRuleType::cases(), + ); + sort($expected); + sort($actual); + + $this->assertSame($expected, $actual); + } + + public function test_from_string_roundtrip_matches_value(): void + { + foreach (FormFieldValidationRuleType::cases() as $case) { + $this->assertSame( + $case, + FormFieldValidationRuleType::from($case->value), + ); + } + } + + public function test_tryfrom_unknown_key_returns_null(): void + { + // Explicit: legacy keys that were NOT migrated to the enum must not + // silently resolve. These come from the Phase A seed scan. + $this->assertNull(FormFieldValidationRuleType::tryFrom('required')); + $this->assertNull(FormFieldValidationRuleType::tryFrom('unique')); + $this->assertNull(FormFieldValidationRuleType::tryFrom('max_priorities')); + $this->assertNull(FormFieldValidationRuleType::tryFrom('tag_categories')); + $this->assertNull(FormFieldValidationRuleType::tryFrom('storage_disk')); + // Legacy ambiguous keys that need field-type dispatch at backfill: + $this->assertNull(FormFieldValidationRuleType::tryFrom('min')); + $this->assertNull(FormFieldValidationRuleType::tryFrom('max')); + } +}