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>
230 lines
8.7 KiB
PHP
230 lines
8.7 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 only the backfill migration (latest WS-5c step).
|
|
$this->artisan('migrate:rollback', ['--step' => 2])->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' => 2])->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' => 2])->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' => 2])->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;
|
|
}
|
|
}
|