feat(form-field): backfill form_fields.options to form_field_options

Atomic data migration. Every options datum in the database — in
form_fields and form_field_library, their translations bags, and the
form_submissions.schema_snapshot + form_templates.schema_snapshot JSON
blobs — is converted to the new relational rich-shape representation.

Strict dispatch per §17.4.4 / §8.7 convention:
  - Fail on field_type ∉ {RADIO, SELECT, MULTISELECT, CHECKBOX_LIST}
    carrying non-null options (post-WS-5b TAG_PICKER seed-bug indicator)
  - Fail on non-flat-string-array options shape
  - Fail on translations.{locale}.options[] length mismatch
  - Fail on non-string / >255-char translated labels
  - Fail on any residual translations.{locale}.options key after
    step C migration

Snapshot rewrite in-place: both form_submissions.schema_snapshot and
form_templates.schema_snapshot walk fields[*] and rewrite options to
the new rich-shape, strip per-locale options[] from the parallel
translations bag. Zero-compromise directive — no reader tolerance for
pre-WS-5d shape in commit 3 onwards.

Rollback reconstructs JSON column shapes plus translations bags.
Forward+back pair safe as a unit; partial rollback unsupported.

FormFieldService::insertFromLibrary switches from JSON-copy to
FormFieldOptionService::copyOptions row-clone per addendum Q3 row-copy
mandate. The field's own translations bag no longer carries
{locale}.options keys — those live on option rows now.

Seeders and factories switch to service-level option creation:
  - FormBuilderDevSeeder.canonicalFields keeps flat-string options as
    its data shape; FormField::create no longer receives an options
    key, the post-create FormFieldOptionService::replaceOptions call
    inserts the rich rows. The same applies to
    seedEventRegistrationShowcaseSchema. The vergoedingstype field's
    legacy {label, description} object shape (a pre-WS-5d seed-bug
    that the strict backfill would reject) is normalised to flat
    strings; the descriptions are dropped.
  - seedSystemTemplates embeds rich-shape options in the template
    snapshot — no flat-array snapshot data remains in newly-seeded
    rows.
  - FormFieldFactory + FormFieldLibraryFactory drop the options
    default; new ::withOptions() helper accepts either flat strings
    (each becomes value+label) or full spec arrays and routes through
    the service.

JSON columns (form_fields.options, form_field_library.options) remain
present and writable via fillable; column-drop lands in commit 5.
Reads from the JSON column still exist in resources, snapshot writer,
FormRequests, FormValueService, and FilterRegistryController — commit
3 switches those all atomically.

Migration step-count tests in WS-5a/b/c bumped by 1 to account for
the new backfill_form_field_options migration on the migration stack.

Tests: 1182 → 1193 green (+11 tests / +56 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 02:21:26 +02:00
parent 11588623c5
commit 15e4e49d8c
10 changed files with 1216 additions and 38 deletions

View File

@@ -33,14 +33,14 @@ final class FormFieldBindingMigrationTest extends TestCase
public function test_forward_migrations_backfill_rows_from_both_json_sources(): void
{
// Roll back to pre-WS-5a state: 1 WS-5d migration (create-options) +
// 4 WS-5c migrations (drop-conditional-logic-col,
// Roll back to pre-WS-5a state: 2 WS-5d migrations (backfill-options,
// create-options) + 4 WS-5c migrations (drop-conditional-logic-col,
// backfill-conditional-logic, create-conditional-logic-conditions,
// create-conditional-logic-groups) + 5 WS-5b migrations
// (drop-validation-cols, configs-backfill, create-configs,
// validation-rules-backfill, create-validation-rules) +
// 2 WS-5a migrations (drop-binding-cols, create-bindings) = 12.
$this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful();
// 2 WS-5a migrations (drop-binding-cols, create-bindings) = 13.
$this->artisan('migrate:rollback', ['--step' => 13])->assertSuccessful();
$this->assertFalse(Schema::hasTable('form_field_bindings'));
$this->assertTrue(Schema::hasColumn('form_fields', 'binding'));
$this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding'));
@@ -101,8 +101,8 @@ final class FormFieldBindingMigrationTest extends TestCase
public function test_rollback_reconstructs_json_and_drops_table(): void
{
// Walk back the full WS-5d + WS-5c + WS-5b + WS-5a stack (12 migrations).
$this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful();
// Walk back the full WS-5d + WS-5c + WS-5b + WS-5a stack (13 migrations).
$this->artisan('migrate:rollback', ['--step' => 13])->assertSuccessful();
[$fieldAId, , ] = $this->seedFieldsWithBindingJson();
[$libAId, ] = $this->seedLibraryWithBindingJson();
@@ -112,12 +112,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-5d (1 migration) + WS-5c (4 migrations) +
// Step back over WS-5d (2 migrations) + WS-5c (4 migrations) +
// WS-5b (5 migrations) in one go → restores the pre-WS-5b state
// (conditional-logic, validation-rules, configs and options tables
// gone, validation_rules JSON columns reappear on source tables;
// binding contract intact).
$this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->assertFalse(Schema::hasTable('form_field_options'));
$this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups'));
$this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions'));

View File

@@ -31,10 +31,11 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_forward_backfill_builds_nested_tree_from_legacy_json(): void
{
// Roll back the WS-5d create-options + WS-5c drop-cl-col + WS-5c
// backfill-cl migrations to land in the conditional-logic JSON-era
// state with no relational form_field_options table yet.
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
// Roll back the WS-5d backfill-options + create-options + WS-5c
// drop-cl-col + WS-5c backfill-cl migrations to land in the
// conditional-logic JSON-era state with no relational
// form_field_options table yet.
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic'));
$fieldId = $this->seedFieldWithJson([
@@ -155,7 +156,7 @@ final class ConditionalLogicBackfillTest extends TestCase
]);
// Roll back only the backfill migration — writes the JSON back.
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$reconstructed = DB::table('form_fields')
->where('id', $fieldId)
@@ -182,7 +183,7 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_unknown_top_level_key_fails_migration(): void
{
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->seedFieldWithJson([
'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]],
@@ -195,7 +196,7 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_unknown_comparison_operator_fails_migration(): void
{
$this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
$this->seedFieldWithJson([
'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]],

View File

@@ -0,0 +1,410 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Options;
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-5d backfill migration, seeds pre-WS-5d JSON into
* `form_fields.options`, `form_field_library.options`, and the
* snapshot blobs, then runs the migration forward and back asserting:
*
* - Forward: rows land in form_field_options with the correct
* owner_type/owner_id/value/label/sort_order and translations
* stripped from the per-locale parallel options[] arrays;
* snapshots rewritten to rich shape.
* - Backward: the rollback pair reconstructs the pre-WS-5d JSON
* shape on every owner row + snapshot.
*/
final class FormFieldOptionsBackfillTest extends TestCase
{
use RefreshDatabase;
public function test_forward_migration_backfills_rows_strips_translations_and_rewrites_snapshots(): void
{
// Roll back only the backfill migration (latest WS-5d step).
// Leaves the form_field_options table in place, JSON columns
// present on the source tables, and snapshots in pre-WS-5d shape.
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->assertTrue(Schema::hasTable('form_field_options'));
$this->assertTrue(Schema::hasColumn('form_fields', 'options'));
[$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson();
$submissionId = $this->seedSubmissionWithSnapshot($selectId);
$templateId = $this->seedTemplateWithSnapshot();
$this->artisan('migrate')->assertSuccessful();
// Forward state: form_field_options rows present.
$selectOptions = DB::table('form_field_options')
->where('owner_type', 'form_field')
->where('owner_id', $selectId)
->orderBy('sort_order')
->get();
$this->assertCount(3, $selectOptions);
$this->assertSame(['XS', 'S', 'M'], $selectOptions->pluck('value')->all());
$this->assertSame(['XS', 'S', 'M'], $selectOptions->pluck('label')->all());
// Translations from form_fields.translations.{locale}.options moved
// onto each option row, indexed by sort_order.
$small = $selectOptions->firstWhere('sort_order', 1);
$this->assertSame(['de' => 'Klein'], json_decode((string) $small->translations, true));
$xs = $selectOptions->firstWhere('sort_order', 0);
$this->assertSame(['de' => 'Größe XS'], json_decode((string) $xs->translations, true));
$multiOptions = DB::table('form_field_options')
->where('owner_type', 'form_field')
->where('owner_id', $multiId)
->orderBy('sort_order')
->get();
$this->assertCount(2, $multiOptions);
$libraryOptions = DB::table('form_field_options')
->where('owner_type', 'form_field_library')
->where('owner_id', $libraryId)
->orderBy('sort_order')
->get();
$this->assertCount(2, $libraryOptions);
$this->assertSame(['lib_a', 'lib_b'], $libraryOptions->pluck('value')->all());
// Per-locale options[] stripped from form_fields.translations.
$remaining = DB::table('form_fields')->where('id', $selectId)->value('translations');
$bag = json_decode((string) $remaining, true);
$this->assertIsArray($bag);
foreach ($bag as $locale => $localeBag) {
$this->assertArrayNotHasKey('options', $localeBag, "locale {$locale} retained options key");
}
// Submission snapshot rewritten to rich shape.
$submission = DB::table('form_submissions')->where('id', $submissionId)->first();
$snapshot = json_decode((string) $submission->schema_snapshot, true);
$field = $snapshot['fields'][0];
$this->assertSame([
['value' => 'XS', 'label' => 'XS', 'sort_order' => 0, 'translations' => ['de' => 'Größe XS']],
['value' => 'S', 'label' => 'S', 'sort_order' => 1, 'translations' => ['de' => 'Klein']],
['value' => 'M', 'label' => 'M', 'sort_order' => 2, 'translations' => ['de' => 'Mittel']],
], $field['options']);
// Field-level translations bag has the {locale}.options key
// stripped.
if (is_array($field['translations'] ?? null)) {
foreach ($field['translations'] as $locale => $localeBag) {
$this->assertArrayNotHasKey('options', $localeBag);
}
}
// Template snapshot rewritten the same way.
$template = DB::table('form_templates')->where('id', $templateId)->first();
$tplSnap = json_decode((string) $template->schema_snapshot, true);
$this->assertSame(
[
['value' => 'A', 'label' => 'A', 'sort_order' => 0],
['value' => 'B', 'label' => 'B', 'sort_order' => 1],
],
$tplSnap['fields'][0]['options'],
);
}
public function test_rollback_reconstructs_json_columns_and_snapshots(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
[$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson();
$submissionId = $this->seedSubmissionWithSnapshot($selectId);
$this->artisan('migrate')->assertSuccessful();
$this->assertSame(
7,
DB::table('form_field_options')->count(),
'3 + 2 + 2 owner rows',
);
// Step back over only the backfill migration → JSON columns repopulate
// and snapshots revert to flat-string-array shape.
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->assertSame(0, DB::table('form_field_options')->count());
$select = DB::table('form_fields')->where('id', $selectId)->first();
$this->assertSame(['XS', 'S', 'M'], json_decode((string) $select->options, true));
$bag = json_decode((string) $select->translations, true);
$this->assertSame(['Größe XS', 'Klein', 'Mittel'], $bag['de']['options']);
$library = DB::table('form_field_library')->where('id', $libraryId)->first();
$this->assertSame(['lib_a', 'lib_b'], json_decode((string) $library->options, true));
$submission = DB::table('form_submissions')->where('id', $submissionId)->first();
$snapshot = json_decode((string) $submission->schema_snapshot, true);
$this->assertSame(['XS', 'S', 'M'], $snapshot['fields'][0]['options']);
$this->assertSame(['Größe XS', 'Klein', 'Mittel'], $snapshot['fields'][0]['translations']['de']['options']);
}
public function test_fails_when_options_present_on_non_option_field_type(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->seedFieldWithOptions('TAG_PICKER', ['Veiligheid', 'Horeca']);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/Stale options on form_fields.*type=TAG_PICKER/');
$this->artisan('migrate')->assertSuccessful();
}
public function test_fails_when_options_contains_non_string_entry(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode([
['label' => 'A'],
['label' => 'B'],
]));
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/Expected flat string array/');
$this->artisan('migrate')->assertSuccessful();
}
public function test_fails_when_options_is_object_shape(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode([
'XS' => 'Extra small',
'S' => 'Small',
]));
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/Expected flat string array/');
$this->artisan('migrate')->assertSuccessful();
}
public function test_fails_on_translations_length_mismatch(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S', 'M']), json_encode([
'de' => ['options' => ['Klein', 'Mittel']], // 2 vs 3
]));
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/Translations length mismatch/');
$this->artisan('migrate')->assertSuccessful();
}
public function test_fails_on_non_string_translation(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S']), json_encode([
'de' => ['options' => ['Klein', 42]],
]));
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/Invalid translated label/');
$this->artisan('migrate')->assertSuccessful();
}
public function test_fails_on_oversized_translation(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS']), json_encode([
'de' => ['options' => [str_repeat('x', 256)]],
]));
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/Invalid translated label/');
$this->artisan('migrate')->assertSuccessful();
}
public function test_fails_when_snapshot_options_present_on_non_option_field_type(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->seedTemplateWithSnapshotRaw([
'fields' => [[
'id' => (string) Str::ulid(),
'slug' => 'tags',
'field_type' => 'TAG_PICKER',
'label' => 'Tags',
'options' => ['Veiligheid'],
]],
]);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/Snapshot.*field_type is TAG_PICKER/');
$this->artisan('migrate')->assertSuccessful();
}
/** @return array{0:string,1:string,2:string} */
private function seedFieldsAndLibraryWithJson(): array
{
$org = Organisation::factory()->create();
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
$selectId = (string) Str::ulid();
$multiId = (string) Str::ulid();
DB::table('form_fields')->insert([
'id' => $selectId,
'form_schema_id' => $schema->id,
'field_type' => 'SELECT',
'slug' => 'shirtmaat',
'label' => 'Shirtmaat',
'options' => json_encode(['XS', 'S', 'M']),
'translations' => json_encode([
'de' => ['label' => 'Größe', 'options' => ['Größe XS', 'Klein', 'Mittel']],
'en' => ['label' => 'Size'],
]),
'value_storage_hint' => 'string',
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('form_fields')->insert([
'id' => $multiId,
'form_schema_id' => $schema->id,
'field_type' => 'MULTISELECT',
'slug' => 'dieet',
'label' => 'Dieet',
'options' => json_encode(['Vegan', 'Halal']),
'translations' => null,
'value_storage_hint' => 'json',
'sort_order' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$libraryId = (string) Str::ulid();
DB::table('form_field_library')->insert([
'id' => $libraryId,
'organisation_id' => $org->id,
'name' => 'Lib Select',
'slug' => 'lib-select',
'field_type' => 'SELECT',
'label' => 'Library',
'options' => json_encode(['lib_a', 'lib_b']),
'default_is_required' => false,
'default_is_filterable' => false,
'usage_count' => 0,
'is_system' => false,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
return [$selectId, $multiId, $libraryId];
}
private function seedSubmissionWithSnapshot(string $fieldId): string
{
$org = Organisation::factory()->create();
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
$submissionId = (string) Str::ulid();
DB::table('form_submissions')->insert([
'id' => $submissionId,
'organisation_id' => $org->id,
'form_schema_id' => $schema->id,
'subject_type' => 'person',
'subject_id' => (string) Str::ulid(),
'status' => 'submitted',
'is_test' => false,
'submitted_in_locale' => 'nl',
'schema_snapshot' => json_encode([
'fields' => [[
'id' => $fieldId,
'slug' => 'shirtmaat',
'field_type' => 'SELECT',
'label' => 'Shirtmaat',
'options' => ['XS', 'S', 'M'],
'translations' => [
'de' => ['label' => 'Größe', 'options' => ['Größe XS', 'Klein', 'Mittel']],
],
]],
]),
'created_at' => now(),
'updated_at' => now(),
]);
return $submissionId;
}
private function seedTemplateWithSnapshot(): string
{
$org = Organisation::factory()->create();
$templateId = (string) Str::ulid();
DB::table('form_templates')->insert([
'id' => $templateId,
'organisation_id' => $org->id,
'name' => 'Tpl',
'slug' => 'tpl',
'purpose' => 'event_registration',
'description' => null,
'schema_snapshot' => json_encode([
'fields' => [[
'id' => (string) Str::ulid(),
'slug' => 'choice',
'field_type' => 'RADIO',
'label' => 'Choice',
'options' => ['A', 'B'],
]],
]),
'is_active' => true,
'is_system' => false,
'created_at' => now(),
'updated_at' => now(),
]);
return $templateId;
}
private function seedFieldWithOptions(string $fieldType, array $options): string
{
return $this->seedFieldWithOptionsRaw($fieldType, json_encode($options));
}
private function seedFieldWithOptionsRaw(string $fieldType, string $optionsJson, ?string $translationsJson = null): 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' => $fieldType,
'slug' => 'fld-'.Str::lower(Str::random(4)),
'label' => 'Test',
'options' => $optionsJson,
'translations' => $translationsJson,
'value_storage_hint' => 'string',
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
return $id;
}
private function seedTemplateWithSnapshotRaw(array $snapshot): string
{
$org = Organisation::factory()->create();
$id = (string) Str::ulid();
DB::table('form_templates')->insert([
'id' => $id,
'organisation_id' => $org->id,
'name' => 'Tpl',
'slug' => 'tpl-'.Str::lower(Str::random(4)),
'purpose' => 'event_registration',
'description' => null,
'schema_snapshot' => json_encode($snapshot),
'is_active' => true,
'is_system' => false,
'created_at' => now(),
'updated_at' => now(),
]);
return $id;
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Options;
use App\Models\FormBuilder\FormFieldLibrary;
use App\Models\FormBuilder\FormFieldOption;
use App\Models\FormBuilder\FormSchema;
use App\Models\Organisation;
use App\Services\FormBuilder\FormFieldOptionService;
use App\Services\FormBuilder\FormFieldService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* WS-5d commit 2 confirms FormFieldService::insertFromLibrary copies
* options via FormFieldOptionService::copyOptions instead of JSON-copy.
* Each library option row is cloned with new ID, owner pointed at the
* new field; translations and sort_order preserved row-for-row.
*/
final class FormFieldServiceInsertFromLibraryOptionsTest extends TestCase
{
use RefreshDatabase;
public function test_insert_from_library_clones_options_via_service_path(): void
{
$org = Organisation::factory()->create();
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
$library = FormFieldLibrary::factory()
->withOptions([
['value' => 'red', 'label' => 'Red', 'sort_order' => 0, 'translations' => ['nl' => 'Rood']],
['value' => 'green', 'label' => 'Green', 'sort_order' => 1],
])
->create(['organisation_id' => $org->id]);
$field = app(FormFieldService::class)->insertFromLibrary($schema, $library);
$optionService = app(FormFieldOptionService::class);
$copied = $optionService->optionsFor($field);
$libraryOptions = $optionService->optionsFor($library);
$this->assertCount(2, $copied);
$this->assertSame(['red', 'green'], $copied->pluck('value')->all());
$this->assertSame([0, 1], $copied->pluck('sort_order')->all());
$this->assertSame(['nl' => 'Rood'], $copied->firstWhere('value', 'red')->translations);
$this->assertNull($copied->firstWhere('value', 'green')->translations);
// Distinct row IDs — options are CLONED, not shared.
$this->assertNotSame(
$libraryOptions->pluck('id')->sort()->values()->all(),
$copied->pluck('id')->sort()->values()->all(),
);
}
public function test_insert_from_library_does_not_carry_legacy_options_translations_key(): void
{
$org = Organisation::factory()->create();
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
$library = FormFieldLibrary::factory()
->withOptions(['a', 'b'])
->create([
'organisation_id' => $org->id,
// Library carries label/help_text translations only — no
// {locale}.options[] parallel array. WS-5d strips that key
// from the FormField's translations bag too.
'translations' => ['nl' => ['label' => 'Bibliotheek']],
]);
$field = app(FormFieldService::class)->insertFromLibrary($schema, $library);
$bag = $field->fresh()->translations ?? [];
if (is_array($bag)) {
foreach ($bag as $localeBag) {
$this->assertArrayNotHasKey('options', is_array($localeBag) ? $localeBag : []);
}
}
}
}

View File

@@ -31,15 +31,15 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
public function test_forward_migration_backfills_rows_with_field_type_dispatch(): void
{
// Roll back: 1 WS-5d migration (create-options) +
// Roll back: 2 WS-5d migrations (backfill-options, create-options) +
// 4 WS-5c migrations (drop-conditional-logic-col,
// backfill-conditional-logic, create-conditional-logic-conditions,
// create-conditional-logic-groups) + 5 WS-5b migrations
// (drop-cols + configs-backfill + create-configs +
// validation-rules-backfill + create-validation-rules) = 10.
// validation-rules-backfill + create-validation-rules) = 11.
// Brings us to the pre-WS-5b state: validation_rules JSON column
// present, no relational tables for WS-5b/c/d.
$this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->assertFalse(Schema::hasTable('form_field_validation_rules'));
$this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
@@ -100,7 +100,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$fieldId = $this->seedFieldWithJson([
'field_type' => 'TAG_PICKER',
@@ -124,7 +124,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$fieldId = $this->seedFieldWithJson([
'field_type' => 'TEXT',
@@ -151,7 +151,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->seedFieldWithJson([
'field_type' => 'TEXT',
@@ -168,7 +168,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// (validation_rules JSON column present; no relational tables for
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
// + validation-rules-backfill + create-validation-rules = 5.
$this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->seedFieldWithJson([
'field_type' => 'BOOLEAN',
@@ -187,7 +187,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// full-back-then-full-forward cycle — rolling back all WS-5b
// migrations restores the pre-WS-5b state (columns present on
// source tables; validation rules relational table gone).
$this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
[$numberId] = $this->seedFields();
$this->artisan('migrate')->assertSuccessful();
@@ -202,7 +202,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase
// Roll back WS-5b fully → column reappears and carries canonical JSON
// reconstructed from the relational rows.
$this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful();
$this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
$field = DB::table('form_fields')->where('id', $numberId)->first();