refactor(form-builder): strict validator + drop form_fields.conditional_logic JSON column

WS-5c commit 3 of 4. FormRequests (Store/Update) now reject bad
conditional_logic trees at the HTTP boundary — the `after()` hook
unwraps the `show_when` envelope, normalises legacy `{all|any: [...]}`
group shape to the service's internal form, and delegates to
`FormFieldConditionalLogicService::assertSpecsValid()`. Unknown
operators, root conditions, empty groups, and unknown field_slug
references produce a 422 with a readable error before any write.

`form_fields.conditional_logic` JSON column dropped. FormField model
`$fillable` and `$casts` no longer mention the column; factory default
no longer writes `null` to it. Snapshot fixtures in the dev seeder and
the legacy-forms migration command keep `conditional_logic` in their
snapshot JSON shape — that's the schema_snapshot contract, not the DB
column.

FormFieldController now maps InvalidConditionalLogicSpecException to
422 alongside FrozenSchemaException / CyclicDependencyException.

Rollback path: roll back WS-5c commits 1–3 together. Partial rollback
(drop-column reversed but backfill still applied) is not a supported
state — matching the WS-5a/b precedent on the family's full-rollback
contract.

Tests: 6 new (strict FormRequest rejection cases + JSON-column drop
assertion). Rollback step counts in WS-5a/b migration tests bumped +1
for the drop_conditional_logic_json_column migration. Baseline
1142 → 1148 green (3085 → 3099 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 00:03:21 +02:00
parent d06ea01b09
commit 079d10975b
12 changed files with 372 additions and 25 deletions

View File

@@ -32,7 +32,7 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_forward_backfill_builds_nested_tree_from_legacy_json(): void
{
// Roll back only the backfill migration (latest WS-5c step).
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
$this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic'));
$fieldId = $this->seedFieldWithJson([
@@ -90,9 +90,11 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_rollback_reconstructs_canonical_json(): void
{
// Starting state: fully migrated. Seed relational rows bypassing the
// service, then roll back one step (backfill) — it should repopulate
// the JSON column.
// 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]);
@@ -105,7 +107,6 @@ final class ConditionalLogicBackfillTest extends TestCase
'label' => 'Subject',
'value_storage_hint' => 'json',
'sort_order' => 0,
'conditional_logic' => null,
'created_at' => now(),
'updated_at' => now(),
]);
@@ -152,7 +153,7 @@ final class ConditionalLogicBackfillTest extends TestCase
]);
// Roll back only the backfill migration — writes the JSON back.
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
$reconstructed = DB::table('form_fields')
->where('id', $fieldId)
@@ -179,7 +180,7 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_unknown_top_level_key_fails_migration(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
$this->seedFieldWithJson([
'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]],
@@ -192,7 +193,7 @@ final class ConditionalLogicBackfillTest extends TestCase
public function test_unknown_comparison_operator_fails_migration(): void
{
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
$this->seedFieldWithJson([
'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]],