feat(form-builder): FormFieldValidationRuleService + legacy backfill + snapshot + library row-copy
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\FormBuilder;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Raised by `FormFieldValidationRuleService::replaceRules()` (and the
|
||||
* FormRequest validator in WS-5b commit 3) when a caller supplies a
|
||||
* rule_type string that does not resolve to a `FormFieldValidationRuleType`
|
||||
* enum case, or a parameter shape that mismatches the rule's schema.
|
||||
*/
|
||||
final class UnknownValidationRuleTypeException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Resources\FormBuilder;
|
||||
|
||||
use App\Models\FormBuilder\FormFieldLibrary;
|
||||
use App\Services\FormBuilder\FormFieldBindingService;
|
||||
use App\Services\FormBuilder\FormFieldValidationRuleService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -28,7 +29,9 @@ final class FormFieldLibraryResource extends JsonResource
|
||||
'label' => $this->label,
|
||||
'help_text' => $this->help_text,
|
||||
'options' => $this->options,
|
||||
'validation_rules' => $this->validation_rules,
|
||||
'validation_rules' => app(FormFieldValidationRuleService::class)->toJsonShape(
|
||||
$this->resource->validationRules,
|
||||
),
|
||||
'default_is_required' => (bool) $this->default_is_required,
|
||||
'default_is_filterable' => (bool) $this->default_is_filterable,
|
||||
'default_binding' => app(FormFieldBindingService::class)->toJsonShape(
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\PersonTag;
|
||||
use App\Services\FormBuilder\FormFieldBindingService;
|
||||
use App\Services\FormBuilder\FormFieldValidationRuleService;
|
||||
use App\Services\FormBuilder\FormLocaleResolver;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
@@ -42,7 +43,9 @@ final class FormFieldResource extends JsonResource
|
||||
$this->field_type === FormFieldType::TAG_PICKER->value,
|
||||
fn () => $this->availableTags(),
|
||||
),
|
||||
'validation_rules' => $this->validation_rules,
|
||||
'validation_rules' => app(FormFieldValidationRuleService::class)->toJsonShape(
|
||||
$this->resource->validationRules,
|
||||
),
|
||||
'is_required' => (bool) $this->is_required,
|
||||
'is_filterable' => (bool) $this->is_filterable,
|
||||
'is_portal_visible' => (bool) $this->is_portal_visible,
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\PersonTag;
|
||||
use App\Models\Scopes\OrganisationScope;
|
||||
use App\Services\FormBuilder\FormFieldValidationRuleService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -37,6 +38,7 @@ final class PublicFormSchemaResource extends JsonResource
|
||||
// already happens at PublicFormTokenResolver::resolve().
|
||||
$this->resource->loadMissing([
|
||||
'fields' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class),
|
||||
'fields.validationRules',
|
||||
'sections' => fn ($q) => $q->withoutGlobalScope(OrganisationScope::class),
|
||||
]);
|
||||
|
||||
@@ -79,7 +81,9 @@ final class PublicFormSchemaResource extends JsonResource
|
||||
'available_tags' => $isTagPicker
|
||||
? $this->tagsForField($f, $availableTagsByCategory)
|
||||
: null,
|
||||
'validation_rules' => $f->validation_rules,
|
||||
'validation_rules' => app(FormFieldValidationRuleService::class)->toJsonShape(
|
||||
$f->validationRules,
|
||||
),
|
||||
'is_required' => (bool) $f->is_required,
|
||||
'display_width' => $f->display_width instanceof \BackedEnum ? $f->display_width->value : $f->display_width,
|
||||
'conditional_logic' => $f->conditional_logic,
|
||||
|
||||
@@ -27,6 +27,7 @@ final class FormFieldService
|
||||
public function __construct(
|
||||
private readonly FormSchemaService $schemaService,
|
||||
private readonly FormFieldBindingService $bindingService,
|
||||
private readonly FormFieldValidationRuleService $validationRuleService,
|
||||
) {}
|
||||
|
||||
public function create(FormSchema $schema, array $data): FormField
|
||||
@@ -216,6 +217,7 @@ final class FormFieldService
|
||||
$field = FormField::create($data);
|
||||
|
||||
$this->bindingService->copyBindings($library, $field);
|
||||
$this->validationRuleService->copyRules($library, $field);
|
||||
|
||||
FormFieldLibrary::query()->whereKey($library->id)->increment('usage_count');
|
||||
|
||||
|
||||
312
api/app/Services/FormBuilder/FormFieldValidationRuleService.php
Normal file
312
api/app/Services/FormBuilder/FormFieldValidationRuleService.php
Normal file
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\FormBuilder;
|
||||
|
||||
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 Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Owns all writes to `form_field_validation_rules`. Single source of truth
|
||||
* for:
|
||||
*
|
||||
* - rule-type enum + per-rule-type parameter shape enforcement
|
||||
* - callback-key validation against `config('form_builder.validation_callbacks')`
|
||||
* (ARCH-FORM-BUILDER §17.4)
|
||||
* - library → field row-copy on insertFromLibrary (addendum Q3 row-copy mandate)
|
||||
* - serialisation of rows into the legacy-format flat JSON bag consumed
|
||||
* by the snapshot writer and API resources
|
||||
*
|
||||
* Pattern: one row per (owner, rule_type). An empty specs array on
|
||||
* `replaceRules()` clears all rules for the owner.
|
||||
*
|
||||
* Activity log convention — matches WS-5a: emit
|
||||
* `field.validation_rules_replaced` on the parent `FormField` only, not on
|
||||
* `FormFieldLibrary`. Library-level audits live elsewhere.
|
||||
*/
|
||||
final class FormFieldValidationRuleService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, FormFieldValidationRule>
|
||||
*/
|
||||
public function rulesFor(FormField|FormFieldLibrary $owner): Collection
|
||||
{
|
||||
$type = $this->ownerTypeFor($owner);
|
||||
|
||||
return FormFieldValidationRule::query()
|
||||
->where('owner_type', $type)
|
||||
->where('owner_id', $owner->getKey())
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the full rule set on an owner transactionally. Validates every
|
||||
* spec's rule_type + parameter shape before any write lands.
|
||||
*
|
||||
* @param list<array{rule_type:string,parameters?:array<string,mixed>,error_message_key?:?string}> $specs
|
||||
*/
|
||||
public function replaceRules(FormField|FormFieldLibrary $owner, array $specs): void
|
||||
{
|
||||
foreach ($specs as $spec) {
|
||||
$this->assertSpecValid($spec);
|
||||
}
|
||||
|
||||
$ownerType = $this->ownerTypeFor($owner);
|
||||
|
||||
DB::transaction(function () use ($owner, $ownerType, $specs): void {
|
||||
FormFieldValidationRule::query()
|
||||
->withoutGlobalScopes()
|
||||
->where('owner_type', $ownerType)
|
||||
->where('owner_id', $owner->getKey())
|
||||
->delete();
|
||||
|
||||
foreach ($specs as $spec) {
|
||||
FormFieldValidationRule::query()->withoutGlobalScopes()->create([
|
||||
'owner_type' => $ownerType,
|
||||
'owner_id' => $owner->getKey(),
|
||||
'rule_type' => $spec['rule_type'],
|
||||
'parameters' => $spec['parameters'] ?? [],
|
||||
'error_message_key' => $spec['error_message_key'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($owner instanceof FormField) {
|
||||
$owner->logFieldChange('field.validation_rules_replaced', [
|
||||
'count' => count($specs),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Row-copy from a library entry to a freshly-inserted field (addendum
|
||||
* Q3 row-copy mandate). Every column is preserved; only `owner_type` /
|
||||
* `owner_id` change.
|
||||
*/
|
||||
public function copyRules(FormFieldLibrary $from, FormField $to): void
|
||||
{
|
||||
$rules = $this->rulesFor($from);
|
||||
|
||||
if ($rules->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($rules, $to): void {
|
||||
foreach ($rules as $rule) {
|
||||
FormFieldValidationRule::query()->withoutGlobalScopes()->create([
|
||||
'owner_type' => 'form_field',
|
||||
'owner_id' => $to->id,
|
||||
'rule_type' => $rule->rule_type instanceof FormFieldValidationRuleType
|
||||
? $rule->rule_type->value
|
||||
: (string) $rule->rule_type,
|
||||
'parameters' => (array) $rule->parameters,
|
||||
'error_message_key' => $rule->error_message_key,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise a rule collection to the flat JSON bag shape consumed by
|
||||
* snapshot writer and API resources. Returns null on empty — matches
|
||||
* pre-WS-5b `validation_rules: null`.
|
||||
*
|
||||
* Shape uses the canonical rule_type keys (new post-WS-5b names — the
|
||||
* legacy ambiguous `min` / `max` were renamed at backfill to
|
||||
* `min_length`/`min_value` etc., and `max_priorities` canonicalised
|
||||
* to `max_selected`).
|
||||
*
|
||||
* @param Collection<int, FormFieldValidationRule> $rules
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function toJsonShape(Collection $rules): ?array
|
||||
{
|
||||
if ($rules->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($rules as $rule) {
|
||||
$type = $rule->rule_type instanceof FormFieldValidationRuleType
|
||||
? $rule->rule_type->value
|
||||
: (string) $rule->rule_type;
|
||||
$params = (array) $rule->parameters;
|
||||
|
||||
$out[$type] = $this->flattenParameters($type, $params);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical flat rendering per rule_type — mirrors the pre-WS-5b bag
|
||||
* shape convention (scalar value for single-value rules, array for
|
||||
* collections, boolean `true` for no-param markers).
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private function flattenParameters(string $ruleType, array $params): mixed
|
||||
{
|
||||
return match ($ruleType) {
|
||||
FormFieldValidationRuleType::MinLength->value,
|
||||
FormFieldValidationRuleType::MaxLength->value,
|
||||
FormFieldValidationRuleType::MinValue->value,
|
||||
FormFieldValidationRuleType::MaxValue->value,
|
||||
FormFieldValidationRuleType::MinSelected->value,
|
||||
FormFieldValidationRuleType::MaxSelected->value => $params['value'] ?? null,
|
||||
|
||||
FormFieldValidationRuleType::MaxFileSize->value => $params['bytes'] ?? null,
|
||||
|
||||
FormFieldValidationRuleType::Regex->value => isset($params['flags']) && $params['flags'] !== ''
|
||||
? ['pattern' => $params['pattern'] ?? '', 'flags' => $params['flags']]
|
||||
: ($params['pattern'] ?? ''),
|
||||
|
||||
FormFieldValidationRuleType::AllowedMimeTypes->value => $params['mime_types'] ?? [],
|
||||
|
||||
FormFieldValidationRuleType::DateMin->value,
|
||||
FormFieldValidationRuleType::DateMax->value => $params['date'] ?? null,
|
||||
|
||||
FormFieldValidationRuleType::Callback->value => $params['key'] ?? '',
|
||||
|
||||
FormFieldValidationRuleType::EmailFormat->value,
|
||||
FormFieldValidationRuleType::UrlFormat->value,
|
||||
FormFieldValidationRuleType::PhoneE164->value => true,
|
||||
|
||||
default => $params,
|
||||
};
|
||||
}
|
||||
|
||||
private function ownerTypeFor(FormField|FormFieldLibrary $owner): string
|
||||
{
|
||||
return $owner instanceof FormField ? 'form_field' : 'form_field_library';
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $spec */
|
||||
private function assertSpecValid(array $spec): void
|
||||
{
|
||||
$ruleTypeRaw = (string) ($spec['rule_type'] ?? '');
|
||||
$enum = FormFieldValidationRuleType::tryFrom($ruleTypeRaw);
|
||||
if ($enum === null) {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation rule_type '{$ruleTypeRaw}' is not a registered "
|
||||
.'FormFieldValidationRuleType case.',
|
||||
);
|
||||
}
|
||||
|
||||
$params = (array) ($spec['parameters'] ?? []);
|
||||
$this->assertParametersMatchShape($enum, $params);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function assertParametersMatchShape(FormFieldValidationRuleType $type, array $params): void
|
||||
{
|
||||
switch ($type) {
|
||||
case FormFieldValidationRuleType::MinLength:
|
||||
case FormFieldValidationRuleType::MaxLength:
|
||||
case FormFieldValidationRuleType::MinSelected:
|
||||
case FormFieldValidationRuleType::MaxSelected:
|
||||
$this->requireInt($type, $params, 'value');
|
||||
|
||||
return;
|
||||
|
||||
case FormFieldValidationRuleType::MinValue:
|
||||
case FormFieldValidationRuleType::MaxValue:
|
||||
$this->requireNumeric($type, $params, 'value');
|
||||
|
||||
return;
|
||||
|
||||
case FormFieldValidationRuleType::MaxFileSize:
|
||||
$this->requireInt($type, $params, 'bytes');
|
||||
|
||||
return;
|
||||
|
||||
case FormFieldValidationRuleType::Regex:
|
||||
$this->requireString($type, $params, 'pattern');
|
||||
|
||||
return;
|
||||
|
||||
case FormFieldValidationRuleType::AllowedMimeTypes:
|
||||
if (! isset($params['mime_types']) || ! is_array($params['mime_types'])) {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation rule '{$type->value}' requires parameters.mime_types (array of strings).",
|
||||
);
|
||||
}
|
||||
foreach ($params['mime_types'] as $mime) {
|
||||
if (! is_string($mime) || $mime === '') {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation rule '{$type->value}' parameters.mime_types must be non-empty strings.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
case FormFieldValidationRuleType::DateMin:
|
||||
case FormFieldValidationRuleType::DateMax:
|
||||
$this->requireString($type, $params, 'date');
|
||||
|
||||
return;
|
||||
|
||||
case FormFieldValidationRuleType::Callback:
|
||||
$this->requireString($type, $params, 'key');
|
||||
$registered = (array) config('form_builder.validation_callbacks', []);
|
||||
if (! array_key_exists($params['key'], $registered)) {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation callback '{$params['key']}' is not registered in "
|
||||
.'config/form_builder.php under `validation_callbacks`.',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
case FormFieldValidationRuleType::EmailFormat:
|
||||
case FormFieldValidationRuleType::UrlFormat:
|
||||
case FormFieldValidationRuleType::PhoneE164:
|
||||
// Boolean markers — parameters must be empty or absent. Any
|
||||
// unexpected keys signal a caller bug; reject loudly.
|
||||
if ($params !== []) {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation rule '{$type->value}' takes no parameters; got ".count($params).'.',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function requireInt(FormFieldValidationRuleType $type, array $params, string $key): void
|
||||
{
|
||||
if (! isset($params[$key]) || ! is_int($params[$key])) {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation rule '{$type->value}' requires integer parameters.{$key}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function requireNumeric(FormFieldValidationRuleType $type, array $params, string $key): void
|
||||
{
|
||||
if (! isset($params[$key]) || ! is_numeric($params[$key])) {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation rule '{$type->value}' requires numeric parameters.{$key}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function requireString(FormFieldValidationRuleType $type, array $params, string $key): void
|
||||
{
|
||||
if (! isset($params[$key]) || ! is_string($params[$key]) || $params[$key] === '') {
|
||||
throw new UnknownValidationRuleTypeException(
|
||||
"Validation rule '{$type->value}' requires non-empty string parameters.{$key}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ final class FormSubmissionService
|
||||
private readonly FormLocaleResolver $localeResolver,
|
||||
private readonly FormValueService $valueService,
|
||||
private readonly FormFieldBindingService $bindingService,
|
||||
private readonly FormFieldValidationRuleService $validationRuleService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -200,7 +201,7 @@ final class FormSubmissionService
|
||||
*/
|
||||
private function buildSnapshot(FormSchema $schema): array
|
||||
{
|
||||
$schema->loadMissing(['fields.bindings', 'sections']);
|
||||
$schema->loadMissing(['fields.bindings', 'fields.validationRules', 'sections']);
|
||||
|
||||
return [
|
||||
'schema_version' => $schema->version,
|
||||
@@ -232,7 +233,7 @@ final class FormSubmissionService
|
||||
'help_text' => $f->help_text,
|
||||
'section_slug' => $this->sectionSlug($schema, $f->form_schema_section_id),
|
||||
'options' => $f->options,
|
||||
'validation_rules' => $f->validation_rules,
|
||||
'validation_rules' => $this->validationRuleService->toJsonShape($f->validationRules),
|
||||
'is_required' => (bool) $f->is_required,
|
||||
'is_filterable' => (bool) $f->is_filterable,
|
||||
'is_pii' => (bool) $f->is_pii,
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Enums\FormBuilder\FormFieldValidationRuleType;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* WS-5b commit 2 of 5 — translates pre-WS-5b validation-rules JSON on
|
||||
* `form_fields` and `form_field_library` into rows in the new relational
|
||||
* `form_field_validation_rules` table. Per addendum Q3 strict-enterprise
|
||||
* decisions:
|
||||
*
|
||||
* - `required` keys: WARN-log and skip. `is_required` column is the
|
||||
* single source of truth (pre-existing convention).
|
||||
* - `unique` keys: WARN-log and skip. `is_unique` column is the
|
||||
* single source of truth (WS-5b consolidation decision).
|
||||
* - `tag_categories` / `storage_disk`: skipped with a log line here —
|
||||
* commit 5's backfill migration picks them up into the separate
|
||||
* `form_field_configs` table (ARCH §17.5).
|
||||
* - `max_priorities`: canonicalised to rule_type `max_selected`.
|
||||
* Both keys share the same semantic (cap on entries in a list-valued
|
||||
* field); two enum cases would be rot. The seeder and assertions
|
||||
* are updated in parallel to use the canonical name directly.
|
||||
* - `min` / `max`: ambiguous legacy keys. Field-type-driven dispatch:
|
||||
* NUMBER → min_value / max_value
|
||||
* TEXT/TEXTAREA/EMAIL/PHONE/URL → min_length / max_length
|
||||
* DATE/DATETIME → date_min / date_max
|
||||
* anything else → FAIL the migration.
|
||||
* Strict FAIL on unmapped types: type-inappropriate uses of `min` /
|
||||
* `max` are seed-data bugs. Force correction, don't absorb silently.
|
||||
* - Unknown top-level keys: FAIL the migration. Phase A seed-scan
|
||||
* should have caught these; if one slipped through we want the
|
||||
* crash, not the skip.
|
||||
*
|
||||
* Transactional. Rollback reconstructs the JSON bag on both source
|
||||
* tables using the canonical rule_type names (post-rename); it does
|
||||
* NOT resurrect `required` / `unique` / `tag_categories` / `storage_disk`
|
||||
* — those keys never landed in the relational table and are unreachable
|
||||
* from this migration's perspective. The forward+back pair is safe when
|
||||
* run as a unit; a partial "rollback this migration but not its
|
||||
* create-table sibling" state is not supported.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::transaction(function (): void {
|
||||
$this->backfill('form_fields', 'form_field');
|
||||
$this->backfill('form_field_library', 'form_field_library');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('form_field_validation_rules')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function (): void {
|
||||
$this->reconstructJson('form_fields', 'form_field');
|
||||
$this->reconstructJson('form_field_library', 'form_field_library');
|
||||
});
|
||||
}
|
||||
|
||||
private function backfill(string $table, string $ownerType): void
|
||||
{
|
||||
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'validation_rules')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = DB::table($table)
|
||||
->whereNotNull('validation_rules')
|
||||
->orderBy('id')
|
||||
->get(['id', 'field_type', 'validation_rules']);
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$inserts = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$raw = $row->validation_rules;
|
||||
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
if (! is_array($decoded) || $decoded === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldType = (string) ($row->field_type ?? '');
|
||||
|
||||
foreach ($decoded as $key => $value) {
|
||||
$translated = $this->translateKey($ownerType, $row->id, $fieldType, (string) $key, $value);
|
||||
if ($translated === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$inserts[] = [
|
||||
'id' => (string) Str::ulid(),
|
||||
'owner_type' => $ownerType,
|
||||
'owner_id' => (string) $row->id,
|
||||
'rule_type' => $translated['rule_type'],
|
||||
'parameters' => json_encode($translated['parameters']),
|
||||
'error_message_key' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($inserts === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (array_chunk($inserts, 500) as $batch) {
|
||||
DB::table('form_field_validation_rules')->insert($batch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{rule_type:string, parameters:array<string,mixed>}|null
|
||||
*/
|
||||
private function translateKey(
|
||||
string $ownerType,
|
||||
string $ownerId,
|
||||
string $fieldType,
|
||||
string $key,
|
||||
mixed $value,
|
||||
): ?array {
|
||||
// Column-owned keys: not migrated. WARN-log defensively; these
|
||||
// should not appear in the wild per Phase A seed scan.
|
||||
if ($key === 'required' || $key === 'unique') {
|
||||
Log::warning(sprintf(
|
||||
'form_field_validation_rules backfill: skipping legacy `%s` key on %s/%s — '
|
||||
.'column is-%s is the source of truth.',
|
||||
$key, $ownerType, $ownerId, $key,
|
||||
));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Configs keys: handled by commit 5's configs-backfill migration.
|
||||
if ($key === 'tag_categories' || $key === 'storage_disk') {
|
||||
Log::info(sprintf(
|
||||
'form_field_validation_rules backfill: skipping `%s` key on %s/%s — '
|
||||
.'will be picked up by the form_field_configs backfill (WS-5b commit 5).',
|
||||
$key, $ownerType, $ownerId,
|
||||
));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Canonicalisations.
|
||||
if ($key === 'max_priorities') {
|
||||
return [
|
||||
'rule_type' => FormFieldValidationRuleType::MaxSelected->value,
|
||||
'parameters' => ['value' => (int) $value],
|
||||
];
|
||||
}
|
||||
|
||||
// Ambiguous legacy min/max → field-type-driven dispatch.
|
||||
if ($key === 'min' || $key === 'max') {
|
||||
return $this->dispatchMinMax($ownerType, $ownerId, $fieldType, $key, $value);
|
||||
}
|
||||
|
||||
// Catalogue-enumerated keys: direct mapping with parameter wrapping.
|
||||
$enum = FormFieldValidationRuleType::tryFrom($key);
|
||||
if ($enum === null) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
'form_field_validation_rules backfill: unknown validation rule key `%s` on %s/%s. '
|
||||
.'Phase A seed-scan did not surface this; inspect the data and either register '
|
||||
.'the key in the FormFieldValidationRuleType enum or clean the source row.',
|
||||
$key, $ownerType, $ownerId,
|
||||
));
|
||||
}
|
||||
|
||||
return [
|
||||
'rule_type' => $enum->value,
|
||||
'parameters' => $this->wrapParameters($enum, $value),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{rule_type:string, parameters:array<string,mixed>}
|
||||
*/
|
||||
private function dispatchMinMax(
|
||||
string $ownerType,
|
||||
string $ownerId,
|
||||
string $fieldType,
|
||||
string $key,
|
||||
mixed $value,
|
||||
): array {
|
||||
$isNumeric = in_array($fieldType, [FormFieldType::NUMBER->value], true);
|
||||
$isString = in_array($fieldType, [
|
||||
FormFieldType::TEXT->value,
|
||||
FormFieldType::TEXTAREA->value,
|
||||
FormFieldType::EMAIL->value,
|
||||
FormFieldType::PHONE->value,
|
||||
FormFieldType::URL->value,
|
||||
], true);
|
||||
$isDate = in_array($fieldType, [
|
||||
FormFieldType::DATE->value,
|
||||
FormFieldType::DATETIME->value,
|
||||
], true);
|
||||
|
||||
if ($isNumeric) {
|
||||
return [
|
||||
'rule_type' => $key === 'min'
|
||||
? FormFieldValidationRuleType::MinValue->value
|
||||
: FormFieldValidationRuleType::MaxValue->value,
|
||||
'parameters' => ['value' => is_numeric($value) ? $value + 0 : $value],
|
||||
];
|
||||
}
|
||||
|
||||
if ($isString) {
|
||||
return [
|
||||
'rule_type' => $key === 'min'
|
||||
? FormFieldValidationRuleType::MinLength->value
|
||||
: FormFieldValidationRuleType::MaxLength->value,
|
||||
'parameters' => ['value' => (int) $value],
|
||||
];
|
||||
}
|
||||
|
||||
if ($isDate) {
|
||||
return [
|
||||
'rule_type' => $key === 'min'
|
||||
? FormFieldValidationRuleType::DateMin->value
|
||||
: FormFieldValidationRuleType::DateMax->value,
|
||||
'parameters' => ['date' => (string) $value],
|
||||
];
|
||||
}
|
||||
|
||||
throw new \RuntimeException(sprintf(
|
||||
'form_field_validation_rules backfill: cannot dispatch legacy `%s` key on %s/%s with '
|
||||
.'field_type `%s`. Only NUMBER, TEXT/TEXTAREA/EMAIL/PHONE/URL, DATE/DATETIME are '
|
||||
.'mapped; inspect the source data.',
|
||||
$key, $ownerType, $ownerId, $fieldType,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function wrapParameters(FormFieldValidationRuleType $type, mixed $value): array
|
||||
{
|
||||
return match ($type) {
|
||||
FormFieldValidationRuleType::MinLength,
|
||||
FormFieldValidationRuleType::MaxLength,
|
||||
FormFieldValidationRuleType::MinSelected,
|
||||
FormFieldValidationRuleType::MaxSelected => ['value' => (int) $value],
|
||||
|
||||
FormFieldValidationRuleType::MinValue,
|
||||
FormFieldValidationRuleType::MaxValue => ['value' => is_numeric($value) ? $value + 0 : $value],
|
||||
|
||||
FormFieldValidationRuleType::MaxFileSize => ['bytes' => (int) $value],
|
||||
|
||||
FormFieldValidationRuleType::Regex => is_array($value)
|
||||
? array_filter([
|
||||
'pattern' => (string) ($value['pattern'] ?? ''),
|
||||
'flags' => (string) ($value['flags'] ?? ''),
|
||||
], static fn ($v): bool => $v !== '')
|
||||
: ['pattern' => (string) $value],
|
||||
|
||||
FormFieldValidationRuleType::AllowedMimeTypes => [
|
||||
'mime_types' => is_array($value)
|
||||
? array_values(array_map(static fn ($m): string => (string) $m, $value))
|
||||
: [],
|
||||
],
|
||||
|
||||
FormFieldValidationRuleType::DateMin,
|
||||
FormFieldValidationRuleType::DateMax => ['date' => (string) $value],
|
||||
|
||||
FormFieldValidationRuleType::Callback => ['key' => (string) $value],
|
||||
|
||||
FormFieldValidationRuleType::EmailFormat,
|
||||
FormFieldValidationRuleType::UrlFormat,
|
||||
FormFieldValidationRuleType::PhoneE164 => [],
|
||||
};
|
||||
}
|
||||
|
||||
private function reconstructJson(string $table, string $ownerType): void
|
||||
{
|
||||
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'validation_rules')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = DB::table('form_field_validation_rules')
|
||||
->where('owner_type', $ownerType)
|
||||
->orderBy('owner_id')
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$ownerId = (string) $row->owner_id;
|
||||
$grouped[$ownerId] ??= [];
|
||||
$params = json_decode((string) $row->parameters, true);
|
||||
$params = is_array($params) ? $params : [];
|
||||
$grouped[$ownerId][(string) $row->rule_type] = $this->flattenForJson((string) $row->rule_type, $params);
|
||||
}
|
||||
|
||||
foreach ($grouped as $ownerId => $bag) {
|
||||
DB::table($table)
|
||||
->where('id', $ownerId)
|
||||
->update(['validation_rules' => json_encode($bag)]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function flattenForJson(string $ruleType, array $params): mixed
|
||||
{
|
||||
return match ($ruleType) {
|
||||
'min_length', 'max_length', 'min_value', 'max_value',
|
||||
'min_selected', 'max_selected' => $params['value'] ?? null,
|
||||
|
||||
'max_file_size' => $params['bytes'] ?? null,
|
||||
|
||||
'regex' => isset($params['flags']) && $params['flags'] !== ''
|
||||
? ['pattern' => $params['pattern'] ?? '', 'flags' => $params['flags']]
|
||||
: ($params['pattern'] ?? ''),
|
||||
|
||||
'allowed_mime_types' => $params['mime_types'] ?? [],
|
||||
|
||||
'date_min', 'date_max' => $params['date'] ?? null,
|
||||
|
||||
'callback' => $params['key'] ?? '',
|
||||
|
||||
'email_format', 'url_format', 'phone_e164' => true,
|
||||
|
||||
default => $params,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -308,9 +308,12 @@ final class FormBuilderDevSeeder
|
||||
'slug' => 'sectie_voorkeur',
|
||||
'label' => 'Bij welke sectie wil je het liefst werken?',
|
||||
'help_text' => 'Sleep je voorkeuren in volgorde. Nummer 1 is je eerste keuze.',
|
||||
// UI soft cap; the hard cap of 5 lives in
|
||||
// FormValueService shape validation.
|
||||
'validation_rules' => ['max_priorities' => 3],
|
||||
// UI soft cap (hard cap of 5 is in FormValueService shape
|
||||
// validation). Post-WS-5b the UI cap lives as a relational
|
||||
// row with rule_type `max_selected` — see
|
||||
// seedEventRegistrationShowcaseSchema() which populates it
|
||||
// via FormFieldValidationRuleService.
|
||||
'max_selected_ui_cap' => 3,
|
||||
'is_required' => false,
|
||||
'is_filterable' => false,
|
||||
'display_width' => 'full',
|
||||
@@ -357,8 +360,10 @@ final class FormBuilderDevSeeder
|
||||
'version' => 1,
|
||||
]);
|
||||
|
||||
$ruleService = app(\App\Services\FormBuilder\FormFieldValidationRuleService::class);
|
||||
|
||||
foreach (self::showcaseFieldDefinitions() as $sortOrder => $def) {
|
||||
FormField::create([
|
||||
$field = FormField::create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'field_type' => $def['type']->value,
|
||||
'slug' => $def['slug'],
|
||||
@@ -376,6 +381,16 @@ final class FormBuilderDevSeeder
|
||||
'value_storage_hint' => $def['value_storage_hint'] ?? FormValueStorageHint::JSON,
|
||||
'sort_order' => $sortOrder + 1,
|
||||
]);
|
||||
|
||||
// Relational validation rules (WS-5b). The SECTION_PRIORITY
|
||||
// field carries the UI soft cap as a `max_selected` row; other
|
||||
// fields in the showcase have no rules yet.
|
||||
if (isset($def['max_selected_ui_cap'])) {
|
||||
$ruleService->replaceRules($field, [[
|
||||
'rule_type' => \App\Enums\FormBuilder\FormFieldValidationRuleType::MaxSelected->value,
|
||||
'parameters' => ['value' => (int) $def['max_selected_ui_cap']],
|
||||
]]);
|
||||
}
|
||||
}
|
||||
|
||||
return $schema->refresh();
|
||||
|
||||
@@ -33,9 +33,10 @@ final class FormFieldBindingMigrationTest extends TestCase
|
||||
|
||||
public function test_forward_migrations_backfill_rows_from_both_json_sources(): void
|
||||
{
|
||||
// Roll back: create_form_field_validation_rules_table (WS-5b commit 1)
|
||||
// Roll back, newest first: backfill_form_field_validation_rules
|
||||
// → create_form_field_validation_rules_table (both WS-5b commit 1+2)
|
||||
// → drop_binding_json_columns → create_form_field_bindings.
|
||||
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
|
||||
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
|
||||
$this->assertFalse(Schema::hasTable('form_field_bindings'));
|
||||
$this->assertTrue(Schema::hasColumn('form_fields', 'binding'));
|
||||
$this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding'));
|
||||
@@ -96,9 +97,10 @@ final class FormFieldBindingMigrationTest extends TestCase
|
||||
|
||||
public function test_rollback_reconstructs_json_and_drops_table(): void
|
||||
{
|
||||
// Walk back: validation-rules table (WS-5b commit 1) →
|
||||
// drop_binding_json_columns → create_form_field_bindings.
|
||||
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
|
||||
// Walk back the full WS-5b + WS-5a stack: backfill (validation rules)
|
||||
// → create (validation rules table) → drop (binding columns) →
|
||||
// create (bindings table).
|
||||
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
|
||||
[$fieldAId, , ] = $this->seedFieldsWithBindingJson();
|
||||
[$libAId, ] = $this->seedLibraryWithBindingJson();
|
||||
|
||||
@@ -108,9 +110,9 @@ 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();
|
||||
// Step back over the two WS-5b migrations → restores the pre-WS-5b
|
||||
// state (validation-rules table gone; binding contract intact).
|
||||
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
|
||||
$this->assertFalse(Schema::hasTable('form_field_validation_rules'));
|
||||
$this->assertTrue(Schema::hasTable('form_field_bindings'));
|
||||
|
||||
|
||||
@@ -187,7 +187,11 @@ final class PublicFormSeederTest extends TestCase
|
||||
$this->assertArrayHasKey('sectie_voorkeur', $fields);
|
||||
$this->assertSame(FormFieldType::SECTION_PRIORITY->value, $fields['sectie_voorkeur']['field_type']);
|
||||
$this->assertSame('full', $fields['sectie_voorkeur']['display_width']);
|
||||
$this->assertSame(['max_priorities' => 3], $fields['sectie_voorkeur']['validation_rules']);
|
||||
// WS-5b canonicalised `max_priorities` → `max_selected` (shared
|
||||
// semantic of "cap on entries in a list-valued field"). Resource
|
||||
// emits the new canonical key via
|
||||
// FormFieldValidationRuleService::toJsonShape().
|
||||
$this->assertSame(['max_selected' => 3], $fields['sectie_voorkeur']['validation_rules']);
|
||||
}
|
||||
|
||||
public function test_time_slots_endpoint_returns_at_least_four_volunteer_rows(): void
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\ValidationRules;
|
||||
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Rolls back the WS-5b commit 1+2 migrations, seeds pre-WS-5b JSON, then
|
||||
* runs the migrations forward and back asserting:
|
||||
*
|
||||
* - Forward: canonical rows land in form_field_validation_rules with
|
||||
* the correct field-type dispatch for legacy `min`/`max` ambiguity
|
||||
* and `max_priorities` canonicalised to `max_selected`.
|
||||
* - Forward: `tag_categories` / `storage_disk` are skipped (handled by
|
||||
* commit 5's configs backfill).
|
||||
* - Rollback: the rows are serialised back into the JSON column using
|
||||
* canonical keys. `required` / `unique` keys are not reconstructed
|
||||
* (never migrated).
|
||||
*/
|
||||
final class FormFieldValidationRuleBackfillTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_forward_migration_backfills_rows_with_field_type_dispatch(): void
|
||||
{
|
||||
// Roll back: backfill + create-table. Brings us to a state where
|
||||
// form_fields.validation_rules exists but form_field_validation_rules
|
||||
// table does not.
|
||||
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
|
||||
$this->assertFalse(Schema::hasTable('form_field_validation_rules'));
|
||||
$this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
|
||||
|
||||
[$numberId, $textId, $dateId, $sectionPriorityId] = $this->seedFields();
|
||||
[$libAId] = $this->seedLibrary();
|
||||
|
||||
$this->artisan('migrate')->assertSuccessful();
|
||||
|
||||
$this->assertTrue(Schema::hasTable('form_field_validation_rules'));
|
||||
|
||||
$numberRules = DB::table('form_field_validation_rules')
|
||||
->where('owner_type', 'form_field')
|
||||
->where('owner_id', $numberId)
|
||||
->get()->keyBy('rule_type');
|
||||
$this->assertTrue($numberRules->has('min_value'));
|
||||
$this->assertTrue($numberRules->has('max_value'));
|
||||
$this->assertSame(16, json_decode((string) $numberRules['min_value']->parameters, true)['value']);
|
||||
$this->assertSame(99, json_decode((string) $numberRules['max_value']->parameters, true)['value']);
|
||||
|
||||
$textRules = DB::table('form_field_validation_rules')
|
||||
->where('owner_type', 'form_field')
|
||||
->where('owner_id', $textId)
|
||||
->get()->keyBy('rule_type');
|
||||
$this->assertTrue($textRules->has('min_length'));
|
||||
$this->assertTrue($textRules->has('max_length'));
|
||||
$this->assertSame(5, json_decode((string) $textRules['min_length']->parameters, true)['value']);
|
||||
$this->assertSame(80, json_decode((string) $textRules['max_length']->parameters, true)['value']);
|
||||
|
||||
$dateRules = DB::table('form_field_validation_rules')
|
||||
->where('owner_type', 'form_field')
|
||||
->where('owner_id', $dateId)
|
||||
->get()->keyBy('rule_type');
|
||||
$this->assertTrue($dateRules->has('date_min'));
|
||||
$this->assertSame('2026-01-01', json_decode((string) $dateRules['date_min']->parameters, true)['date']);
|
||||
|
||||
$sectionRules = DB::table('form_field_validation_rules')
|
||||
->where('owner_type', 'form_field')
|
||||
->where('owner_id', $sectionPriorityId)
|
||||
->get()->keyBy('rule_type');
|
||||
$this->assertTrue($sectionRules->has('max_selected'), 'max_priorities should canonicalise to max_selected');
|
||||
$this->assertSame(3, json_decode((string) $sectionRules['max_selected']->parameters, true)['value']);
|
||||
$this->assertFalse($sectionRules->has('max_priorities'), 'legacy key must not survive');
|
||||
|
||||
$libRules = DB::table('form_field_validation_rules')
|
||||
->where('owner_type', 'form_field_library')
|
||||
->where('owner_id', $libAId)
|
||||
->get()->keyBy('rule_type');
|
||||
$this->assertTrue($libRules->has('regex'));
|
||||
$this->assertSame(
|
||||
'/^[A-Z]{3}$/',
|
||||
json_decode((string) $libRules['regex']->parameters, true)['pattern'],
|
||||
);
|
||||
}
|
||||
|
||||
public function test_tag_categories_and_storage_disk_skipped_for_commit_5(): void
|
||||
{
|
||||
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
|
||||
|
||||
$fieldId = $this->seedFieldWithJson([
|
||||
'field_type' => 'TAG_PICKER',
|
||||
'validation_rules' => [
|
||||
'tag_categories' => ['Veiligheid'],
|
||||
'storage_disk' => 'local',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->artisan('migrate')->assertSuccessful();
|
||||
|
||||
$rows = DB::table('form_field_validation_rules')
|
||||
->where('owner_id', $fieldId)
|
||||
->get();
|
||||
$this->assertCount(0, $rows, 'configs keys must not land in the validation-rules table');
|
||||
}
|
||||
|
||||
public function test_required_and_unique_skipped_with_warn(): void
|
||||
{
|
||||
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
|
||||
|
||||
$fieldId = $this->seedFieldWithJson([
|
||||
'field_type' => 'TEXT',
|
||||
'validation_rules' => [
|
||||
'required' => true,
|
||||
'unique' => true,
|
||||
'min' => 2,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->artisan('migrate')->assertSuccessful();
|
||||
|
||||
$rows = DB::table('form_field_validation_rules')
|
||||
->where('owner_id', $fieldId)
|
||||
->pluck('rule_type')
|
||||
->all();
|
||||
sort($rows);
|
||||
$this->assertSame(['min_length'], $rows, 'only min_length should land (required/unique skipped)');
|
||||
}
|
||||
|
||||
public function test_unknown_top_level_key_fails_migration(): void
|
||||
{
|
||||
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
|
||||
|
||||
$this->seedFieldWithJson([
|
||||
'field_type' => 'TEXT',
|
||||
'validation_rules' => ['nonsense_key' => 42],
|
||||
]);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->artisan('migrate');
|
||||
}
|
||||
|
||||
public function test_unmapped_field_type_for_min_max_fails_migration(): void
|
||||
{
|
||||
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
|
||||
|
||||
$this->seedFieldWithJson([
|
||||
'field_type' => 'BOOLEAN',
|
||||
'validation_rules' => ['min' => 1],
|
||||
]);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->artisan('migrate');
|
||||
}
|
||||
|
||||
public function test_rollback_reconstructs_canonical_json_on_source_tables(): void
|
||||
{
|
||||
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
|
||||
[$numberId] = $this->seedFields();
|
||||
|
||||
$this->artisan('migrate')->assertSuccessful();
|
||||
|
||||
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
|
||||
// Only the backfill rolled back — the create-table migration still
|
||||
// applied, so rows remain accessible (until we step back once more).
|
||||
$this->assertTrue(Schema::hasTable('form_field_validation_rules'));
|
||||
|
||||
$field = DB::table('form_fields')->where('id', $numberId)->first();
|
||||
$decoded = json_decode((string) $field->validation_rules, true);
|
||||
// Rollback reconstructs using canonical keys — the legacy `min`/`max`
|
||||
// are intentionally NOT resurrected (post-rename semantic).
|
||||
$this->assertSame(16, $decoded['min_value']);
|
||||
$this->assertSame(99, $decoded['max_value']);
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string,2:string,3:string} */
|
||||
private function seedFields(): array
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
||||
|
||||
$number = (string) Str::ulid();
|
||||
$text = (string) Str::ulid();
|
||||
$date = (string) Str::ulid();
|
||||
$section = (string) Str::ulid();
|
||||
|
||||
DB::table('form_fields')->insert([
|
||||
$this->row($number, $schema->id, 'NUMBER', 'leeftijd',
|
||||
['min' => 16, 'max' => 99]),
|
||||
$this->row($text, $schema->id, 'TEXT', 'postcode',
|
||||
['min' => 5, 'max' => 80]),
|
||||
$this->row($date, $schema->id, 'DATE', 'startdatum',
|
||||
['min' => '2026-01-01']),
|
||||
$this->row($section, $schema->id, 'SECTION_PRIORITY', 'sectie-voorkeur',
|
||||
['max_priorities' => 3]),
|
||||
]);
|
||||
|
||||
return [$number, $text, $date, $section];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $validationRules */
|
||||
private function row(string $id, string $schemaId, string $fieldType, string $slug, array $validationRules): array
|
||||
{
|
||||
return [
|
||||
'id' => $id,
|
||||
'form_schema_id' => $schemaId,
|
||||
'field_type' => $fieldType,
|
||||
'slug' => $slug,
|
||||
'label' => $slug,
|
||||
'validation_rules' => json_encode($validationRules),
|
||||
'value_storage_hint' => 'json',
|
||||
'sort_order' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{0:string} */
|
||||
private function seedLibrary(): array
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
|
||||
$lib = (string) Str::ulid();
|
||||
DB::table('form_field_library')->insert([
|
||||
[
|
||||
'id' => $lib,
|
||||
'organisation_id' => $org->id,
|
||||
'name' => 'License plate bibliotheek',
|
||||
'slug' => 'kenteken-lib',
|
||||
'field_type' => 'TEXT',
|
||||
'label' => 'Kenteken',
|
||||
'validation_rules' => json_encode(['regex' => '/^[A-Z]{3}$/']),
|
||||
'default_is_required' => false,
|
||||
'default_is_filterable' => false,
|
||||
'usage_count' => 0,
|
||||
'is_system' => false,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
return [$lib];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $attrs */
|
||||
private function seedFieldWithJson(array $attrs): string
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
||||
|
||||
$id = (string) Str::ulid();
|
||||
DB::table('form_fields')->insert([[
|
||||
'id' => $id,
|
||||
'form_schema_id' => $schema->id,
|
||||
'field_type' => $attrs['field_type'],
|
||||
'slug' => 'f-'.Str::lower(Str::random(4)),
|
||||
'label' => 'field',
|
||||
'validation_rules' => json_encode($attrs['validation_rules']),
|
||||
'value_storage_hint' => 'json',
|
||||
'sort_order' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\ValidationRules;
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldValidationRuleType;
|
||||
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\FormFieldService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* FormFieldService::insertFromLibrary must row-copy validation rules from
|
||||
* the library entry to the new field (addendum Q3 row-copy mandate, paired
|
||||
* with the existing bindings row-copy from WS-5a).
|
||||
*/
|
||||
final class InsertFromLibraryCopiesRulesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_library_rules_are_copied_to_new_field(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
|
||||
$library = FormFieldLibrary::factory()->create([
|
||||
'organisation_id' => $org->id,
|
||||
'slug' => 'kenteken',
|
||||
]);
|
||||
FormFieldValidationRule::factory()->forLibrary($library)
|
||||
->ofType(FormFieldValidationRuleType::Regex, ['pattern' => '/^[A-Z]{3}$/'])->create();
|
||||
FormFieldValidationRule::factory()->forLibrary($library)
|
||||
->ofType(FormFieldValidationRuleType::MaxLength, ['value' => 8])->create();
|
||||
|
||||
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
||||
|
||||
/** @var FormField $field */
|
||||
$field = app(FormFieldService::class)->insertFromLibrary($schema, $library);
|
||||
|
||||
$rules = FormFieldValidationRule::query()
|
||||
->where('owner_type', 'form_field')
|
||||
->where('owner_id', $field->id)
|
||||
->get()
|
||||
->keyBy(fn ($r) => $r->rule_type->value);
|
||||
|
||||
$this->assertCount(2, $rules);
|
||||
$this->assertSame('/^[A-Z]{3}$/', $rules['regex']->parameters['pattern']);
|
||||
$this->assertSame(8, $rules['max_length']->parameters['value']);
|
||||
}
|
||||
|
||||
public function test_library_without_rules_produces_field_without_rules(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]);
|
||||
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
||||
|
||||
/** @var FormField $field */
|
||||
$field = app(FormFieldService::class)->insertFromLibrary($schema, $library);
|
||||
|
||||
$count = FormFieldValidationRule::query()
|
||||
->where('owner_type', 'form_field')
|
||||
->where('owner_id', $field->id)
|
||||
->count();
|
||||
$this->assertSame(0, $count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\ValidationRules;
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Enums\FormBuilder\FormFieldValidationRuleType;
|
||||
use App\Enums\FormBuilder\FormSchemaSnapshotMode;
|
||||
use App\Enums\FormBuilder\FormSubmissionMode;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormFieldValidationRule;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\Organisation;
|
||||
use App\Services\FormBuilder\FormSubmissionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `form_submissions.schema_snapshot.fields[*].validation_rules` is now
|
||||
* sourced from the relational table via
|
||||
* `FormFieldValidationRuleService::toJsonShape()`. Given a field with
|
||||
* known rules, the snapshot JSON embeds the canonical shape — parity
|
||||
* check against a freshly-rendered resource output.
|
||||
*/
|
||||
final class SchemaSnapshotValidationRulesParityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_snapshot_embeds_canonical_validation_rules_shape(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $org->id,
|
||||
'submission_mode' => FormSubmissionMode::SINGLE,
|
||||
'snapshot_mode' => FormSchemaSnapshotMode::ON_SUBMIT,
|
||||
]);
|
||||
$field = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'field_type' => FormFieldType::TEXT->value,
|
||||
'slug' => 'voornaam',
|
||||
]);
|
||||
FormFieldValidationRule::factory()->forField($field)
|
||||
->ofType(FormFieldValidationRuleType::MinLength, ['value' => 2])->create();
|
||||
FormFieldValidationRule::factory()->forField($field)
|
||||
->ofType(FormFieldValidationRuleType::MaxLength, ['value' => 40])->create();
|
||||
|
||||
$service = app(FormSubmissionService::class);
|
||||
$snapshot = $this->invokeBuildSnapshot($service, $schema);
|
||||
|
||||
$fieldEntry = collect($snapshot['fields'])->firstWhere('slug', 'voornaam');
|
||||
$this->assertNotNull($fieldEntry);
|
||||
$this->assertSame([
|
||||
'min_length' => 2,
|
||||
'max_length' => 40,
|
||||
], $fieldEntry['validation_rules']);
|
||||
}
|
||||
|
||||
public function test_snapshot_validation_rules_null_when_no_rules(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
||||
FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'slug' => 'noot',
|
||||
]);
|
||||
|
||||
$service = app(FormSubmissionService::class);
|
||||
$snapshot = $this->invokeBuildSnapshot($service, $schema);
|
||||
|
||||
$entry = collect($snapshot['fields'])->firstWhere('slug', 'noot');
|
||||
$this->assertNull($entry['validation_rules']);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function invokeBuildSnapshot(FormSubmissionService $service, FormSchema $schema): array
|
||||
{
|
||||
$ref = new \ReflectionMethod($service, 'buildSnapshot');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
return $ref->invoke($service, $schema);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\ValidationRules;
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Enums\FormBuilder\FormFieldValidationRuleType;
|
||||
use App\Http\Resources\FormBuilder\FormFieldLibraryResource;
|
||||
use App\Http\Resources\FormBuilder\FormFieldResource;
|
||||
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 Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* API-resource contract: the `validation_rules` key in the resource output
|
||||
* is now sourced from the relational table via
|
||||
* `FormFieldValidationRuleService::toJsonShape()`. The shape is the
|
||||
* canonical flat bag (new post-WS-5b key names). Parity means: same
|
||||
* semantic rule → same bag entry.
|
||||
*/
|
||||
final class ValidationRulesResourceParityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_form_field_resource_emits_canonical_shape_from_relational_rules(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
||||
$field = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'field_type' => FormFieldType::TEXT->value,
|
||||
]);
|
||||
FormFieldValidationRule::factory()->forField($field)
|
||||
->ofType(FormFieldValidationRuleType::MinLength, ['value' => 3])->create();
|
||||
FormFieldValidationRule::factory()->forField($field)
|
||||
->ofType(FormFieldValidationRuleType::MaxLength, ['value' => 40])->create();
|
||||
|
||||
$rendered = (new FormFieldResource($field->fresh()))->toArray(request());
|
||||
|
||||
$this->assertSame([
|
||||
'min_length' => 3,
|
||||
'max_length' => 40,
|
||||
], $rendered['validation_rules']);
|
||||
}
|
||||
|
||||
public function test_form_field_resource_emits_null_when_no_rules(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
||||
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
||||
|
||||
$rendered = (new FormFieldResource($field->fresh()))->toArray(request());
|
||||
|
||||
$this->assertNull($rendered['validation_rules']);
|
||||
}
|
||||
|
||||
public function test_form_field_library_resource_emits_canonical_shape(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]);
|
||||
FormFieldValidationRule::factory()->forLibrary($library)
|
||||
->ofType(FormFieldValidationRuleType::AllowedMimeTypes, ['mime_types' => ['image/png', 'image/jpeg']])->create();
|
||||
|
||||
$rendered = (new FormFieldLibraryResource($library->fresh()))->toArray(request());
|
||||
|
||||
$this->assertSame([
|
||||
'allowed_mime_types' => ['image/png', 'image/jpeg'],
|
||||
], $rendered['validation_rules']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user