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>
233 lines
8.9 KiB
PHP
233 lines
8.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\FormBuilder\ConditionalLogic;
|
|
|
|
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 just the WS-5c backfill migration, seeds pre-WS-5c JSON into
|
|
* `form_fields.conditional_logic`, then migrates forward and backward,
|
|
* asserting:
|
|
*
|
|
* - Forward: relational rows mirror the tree shape (groups + conditions
|
|
* + parent links + sort_order).
|
|
* - Rollback: JSON reconstructed byte-accurate for the ARCH §8 shape.
|
|
*
|
|
* Cross-field cycle detection does NOT run on backfill — pre-WS-5c data
|
|
* is assumed acyclic (the JSON-era check enforced on save). Post-backfill
|
|
* the service enforces on every write.
|
|
*/
|
|
final class ConditionalLogicBackfillTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_forward_backfill_builds_nested_tree_from_legacy_json(): void
|
|
{
|
|
// 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([
|
|
'show_when' => [
|
|
'all' => [
|
|
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
|
|
[
|
|
'any' => [
|
|
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'],
|
|
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'BE'],
|
|
],
|
|
],
|
|
['field_slug' => 'status', 'operator' => 'empty'],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$this->artisan('migrate')->assertSuccessful();
|
|
|
|
// Root group: `all`, no parent, 2 conditions + 1 subgroup.
|
|
$rootGroup = DB::table('form_field_conditional_logic_groups')
|
|
->where('form_field_id', $fieldId)
|
|
->whereNull('parent_group_id')
|
|
->first();
|
|
$this->assertNotNull($rootGroup);
|
|
$this->assertSame('all', $rootGroup->operator);
|
|
|
|
$conditions = DB::table('form_field_conditional_logic_conditions')
|
|
->where('group_id', $rootGroup->id)
|
|
->orderBy('sort_order')
|
|
->get();
|
|
$this->assertCount(2, $conditions);
|
|
$this->assertSame('gate', $conditions[0]->field_slug);
|
|
$this->assertSame('equals', $conditions[0]->comparison_operator);
|
|
$this->assertSame('yes', json_decode((string) $conditions[0]->value, true));
|
|
|
|
$this->assertSame('status', $conditions[1]->field_slug);
|
|
$this->assertSame('empty', $conditions[1]->comparison_operator);
|
|
$this->assertNull($conditions[1]->value, 'valueless operator stores null');
|
|
|
|
// Subgroup: `any`, 2 conditions.
|
|
$subGroup = DB::table('form_field_conditional_logic_groups')
|
|
->where('parent_group_id', $rootGroup->id)
|
|
->first();
|
|
$this->assertNotNull($subGroup);
|
|
$this->assertSame('any', $subGroup->operator);
|
|
$subConditions = DB::table('form_field_conditional_logic_conditions')
|
|
->where('group_id', $subGroup->id)
|
|
->orderBy('sort_order')
|
|
->get();
|
|
$this->assertCount(2, $subConditions);
|
|
$this->assertSame('region', $subConditions[0]->field_slug);
|
|
$this->assertSame('NL', json_decode((string) $subConditions[0]->value, true));
|
|
}
|
|
|
|
public function test_rollback_reconstructs_canonical_json(): void
|
|
{
|
|
// Starting state: fully migrated. The conditional_logic column is
|
|
// already gone (WS-5c commit 3 drop). Seed relational rows bypassing
|
|
// the service, then roll back two steps — drop-column reverses first
|
|
// (column re-appears), then the backfill's `down()` reads relational
|
|
// rows and writes the JSON back.
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
|
|
$fieldId = (string) Str::ulid();
|
|
DB::table('form_fields')->insert([
|
|
'id' => $fieldId,
|
|
'form_schema_id' => $schema->id,
|
|
'field_type' => 'TEXT',
|
|
'slug' => 'subject',
|
|
'label' => 'Subject',
|
|
'value_storage_hint' => 'json',
|
|
'sort_order' => 0,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
$rootId = (string) Str::ulid();
|
|
DB::table('form_field_conditional_logic_groups')->insert([
|
|
'id' => $rootId,
|
|
'form_field_id' => $fieldId,
|
|
'parent_group_id' => null,
|
|
'operator' => 'all',
|
|
'sort_order' => 0,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
DB::table('form_field_conditional_logic_conditions')->insert([
|
|
'id' => (string) Str::ulid(),
|
|
'group_id' => $rootId,
|
|
'field_slug' => 'gate',
|
|
'comparison_operator' => 'equals',
|
|
'value' => json_encode('yes'),
|
|
'sort_order' => 0,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
$subGroupId = (string) Str::ulid();
|
|
DB::table('form_field_conditional_logic_groups')->insert([
|
|
'id' => $subGroupId,
|
|
'form_field_id' => $fieldId,
|
|
'parent_group_id' => $rootId,
|
|
'operator' => 'any',
|
|
'sort_order' => 1,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
DB::table('form_field_conditional_logic_conditions')->insert([
|
|
'id' => (string) Str::ulid(),
|
|
'group_id' => $subGroupId,
|
|
'field_slug' => 'region',
|
|
'comparison_operator' => 'equals',
|
|
'value' => json_encode('NL'),
|
|
'sort_order' => 0,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
// Roll back only the backfill migration — writes the JSON back.
|
|
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
|
|
|
|
$reconstructed = DB::table('form_fields')
|
|
->where('id', $fieldId)
|
|
->value('conditional_logic');
|
|
$this->assertNotNull($reconstructed);
|
|
$json = json_decode((string) $reconstructed, true);
|
|
$this->assertSame([
|
|
'show_when' => [
|
|
'all' => [
|
|
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
|
|
[
|
|
'any' => [
|
|
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
], $json);
|
|
|
|
// Relational tables cleared after reconstruction.
|
|
$this->assertSame(0, DB::table('form_field_conditional_logic_groups')->count());
|
|
$this->assertSame(0, DB::table('form_field_conditional_logic_conditions')->count());
|
|
}
|
|
|
|
public function test_unknown_top_level_key_fails_migration(): void
|
|
{
|
|
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
|
|
|
|
$this->seedFieldWithJson([
|
|
'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]],
|
|
]);
|
|
|
|
$this->expectException(\RuntimeException::class);
|
|
$this->expectExceptionMessageMatches('/hide_when/');
|
|
$this->artisan('migrate');
|
|
}
|
|
|
|
public function test_unknown_comparison_operator_fails_migration(): void
|
|
{
|
|
$this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful();
|
|
|
|
$this->seedFieldWithJson([
|
|
'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]],
|
|
]);
|
|
|
|
$this->expectException(\RuntimeException::class);
|
|
$this->expectExceptionMessageMatches('/matches_regex/');
|
|
$this->artisan('migrate');
|
|
}
|
|
|
|
/** @param array<string, mixed> $json */
|
|
private function seedFieldWithJson(array $json): 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' => 'TEXT',
|
|
'slug' => 'f-'.Str::lower(Str::random(4)),
|
|
'label' => 'field',
|
|
'value_storage_hint' => 'json',
|
|
'sort_order' => 0,
|
|
'conditional_logic' => json_encode($json),
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
return $id;
|
|
}
|
|
}
|