artisan('migrate:rollback', ['--step' => 10])->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' => 10])->assertSuccessful(); $reconstructed = DB::table('form_fields') ->where('id', $fieldId) ->value('conditional_logic'); $this->assertNotNull($reconstructed); $json = json_decode((string) $reconstructed, true); // RFC-WS-6 session 2.7: migration's down() reconstructs JSON via // raw DB writer (not the canonicalizing service). Compare on // canonical form so the assertion is engine-agnostic. $this->assertSame( JsonCanonicalizer::encode([ 'show_when' => [ 'all' => [ ['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'], [ 'any' => [ ['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'], ], ], ], ], ]), JsonCanonicalizer::encode($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' => 10])->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' => 10])->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 $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; } }