Final WS-5d cleanup. The JSON columns that have been unread since
commit 3 are now physically dropped on both source tables. Their
canonical rich-shape lives in form_field_options, accessed
exclusively through the morphMany relation.
Defensive sweep: any lingering translations.{locale}.options key in
either source table's translations bag is stripped. Commit 2's
backfill should already have done so exhaustively; this is
belt-and-braces.
Rollback re-creates the columns as nullable JSON but leaves them
empty. Pair with commit 2's rollback to restore the pre-WS-5d data
shape on every owner row.
The commit-3 getOptionsAttribute accessor-bridge on FormField +
FormFieldLibrary is removed — Eloquent's getAttribute() resolution
now naturally falls through to the morphMany relation since there's
no underlying column to shadow it. New regression test
FormFieldOptionsAccessTest asserts $field->options resolves to an
Eloquent Collection of FormFieldOption instances and lazy-loads in
exactly 2 queries (1 parent + 1 lazy-load options) on a fresh fetch
without with() preload. Same trio for FormFieldLibrary.
Migration step-count tests in WS-5a/b/c bumped by 1 to account for
the new drop_form_field_options_json_columns migration on the
rollback stack.
Documentation:
- SCHEMA.md v2.6: form_field_options table documented; options row
removed from form_fields and form_field_library; morphMany
relations updated; cross-references to ARCH-FORM-BUILDER §17.6
and addendum §Q3 WS-5d Uitvoering added on both source-table
docblocks.
- ARCH-FORM-BUILDER.md v1.8: new §17.6 "Field options (relational)"
mirrors the §17.4 / §17.5 relational-sibling structure with
sub-sections 17.6.1 rationale, 17.6.2 table + catalogue, 17.6.3
service / scope / cascade / activity log, 17.6.4 snapshot
embedding, 17.6.5 external API contract. Existing Webhooks
section renumbered from §17.6 to §17.7.
- ARCH-CONSOLIDATION-ADDENDUM-2026-04-24.md: "Uitvoering — WS-5d
(2026-04-27)" section added. Eight paragraphs covering the
snapshot atomic rewrite, strict-fail backfill dispatch, dual
activity-log emit, four-sibling base-class extraction warrant,
commit 0 dead-code precondition, the temporary getOptionsAttribute
accessor-bridge pattern (with reusability note for future
JSON→relational refactors), the dev-seeder vergoedingstype RADIO
normalisation (drift correction explicitly distinguished from the
parallel apps/app RegistrationFieldTemplate description domain),
and the WS-5 family completion note.
- BACKLOG.md: FORM-BUILDER-LIBRARY-AUDIT-LOG entry extended to four
services (adds library.options_replaced); new
FORM-BUILDER-MORPH-SCOPE-BASE-CLASS entry added as the WS-5d
follow-up now that all four concrete morph-scope siblings exist.
Tests: 1193 → 1208 green (+15 across commits 3+4+5; this commit alone:
+2 from the regression test).
This completes the WS-5 family.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
411 lines
16 KiB
PHP
411 lines
16 KiB
PHP
<?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' => 2])->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' => 2])->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' => 2])->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' => 2])->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' => 2])->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' => 2])->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' => 2])->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' => 2])->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' => 2])->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' => 2])->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;
|
|
}
|
|
}
|