Session 2's PersonProvisioner picked CrowdType::oldest() for the org — silently wrong for multi-crowd_type orgs (Volunteer + Crew + Press are three distinct crowd_types in one org). Schemas now declare their target crowd_type explicitly via form_schemas.default_crowd_type_id. RequiresDefaultCrowdType publish guard prevents misconfigured event_registration schemas from publishing. PersonProvisioner: oldest() fallback removed entirely. Misconfiguration throws no_default_crowd_type at runtime; publish guard prevents it at config time. Migration uses a plain ulid() column without DB-level FK because SQLite's table-rebuild on ALTER ADD FOREIGN KEY cascade-deletes form_fields rows (form_fields.form_schema_id has cascadeOnDelete on form_schemas). Application-level integrity via FormSchema::defaultCrowdType() belongsTo + the publish guard + the runtime failsafe — three load-bearing checks, none of which require the DB-level constraint. Three pre-existing migration backfill tests bumped step counts +1 to account for the new migration sitting between WS-5c and WS-5d: FormFieldBindingMigrationTest (16→17, 14→15), FormFieldConfigBackfillAndDropTest (11→12), FormFieldValidationRuleBackfillTest (14→15), ConditionalLogicBackfillTest (5→6). Six event_registration test fixtures updated to set default_crowd_type_id to satisfy the new publish guard. FormBuilderDevSeeder.resolveDefaultCrowdTypeId() — VOLUNTEER → first-active → create-as-needed fallback chain; documented contract for future seeders. SCHEMA.md updated to v2.7. Refs: RFC-WS-6.md v1.1 §3 Q8 addendum (Task 4 of this session) 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 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' => 6])->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' => 6])->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' => 6])->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' => 6])->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;
|
|
}
|
|
}
|