Same root cause as 832375b — the new failure_response_code migration
sits at the top of the WS-5/WS-6 stack, so every test that pins --step
to walk back through that stack needs +1.
- FormFieldOptionsBackfillTest: 6 -> 7 (10 occurrences)
- ConditionalLogicBackfillTest: 10 -> 11 (4 occurrences)
- FormFieldConfigBackfillAndDropTest: 16 -> 17 (1 occurrence)
- FormFieldValidationRuleBackfillTest: 19 -> 20 (7 occurrences)
Total: 22 backfill tests now green again.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
253 lines
9.9 KiB
PHP
253 lines
9.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\FormBuilder\ConditionalLogic;
|
|
|
|
use App\Models\FormBuilder\FormSchema;
|
|
use App\Models\Organisation;
|
|
use App\Support\Json\JsonCanonicalizer;
|
|
use Illuminate\Foundation\Testing\RefreshDatabaseState;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
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
|
|
{
|
|
// Migration tests run DDL inside the test body; RefreshDatabase + MySQL
|
|
// implicit-commit the savepoint, breaking ROLLBACK TO SAVEPOINT at
|
|
// end-of-test (1305 SAVEPOINT does not exist). Use migrate:fresh per
|
|
// test for a clean baseline without the txn wrapper.
|
|
//
|
|
// RefreshDatabaseState::$migrated = false forces the NEXT RefreshDatabase
|
|
// test to re-migrate fresh, so any data this test commits doesn't leak.
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Artisan::call('migrate:fresh');
|
|
RefreshDatabaseState::$migrated = false;
|
|
}
|
|
|
|
public function test_forward_backfill_builds_nested_tree_from_legacy_json(): void
|
|
{
|
|
// Roll back the WS-5d drop-options-cols + 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' => 11])->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' => 11])->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' => 11])->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' => 11])->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;
|
|
}
|
|
}
|