refactor(form-builder): pre-publish check reads form_field_bindings; drop binding JSON columns
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Bindings;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class BindingJsonColumnsDroppedTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_form_fields_has_no_binding_column(): void
|
||||
{
|
||||
$this->assertFalse(Schema::hasColumn('form_fields', 'binding'));
|
||||
}
|
||||
|
||||
public function test_form_field_library_has_no_default_binding_column(): void
|
||||
{
|
||||
$this->assertFalse(Schema::hasColumn('form_field_library', 'default_binding'));
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Bindings;
|
||||
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormFieldLibrary;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -15,44 +13,48 @@ use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Seeds pre-migration JSON into `form_fields.binding` and
|
||||
* `form_field_library.default_binding`, then runs the migration forward
|
||||
* and back asserting both directions.
|
||||
* Rolls back both WS-5a migrations (drop-columns + create-table), seeds
|
||||
* pre-migration JSON into `form_fields.binding` and
|
||||
* `form_field_library.default_binding`, then runs the migrations forward
|
||||
* and back asserting:
|
||||
*
|
||||
* Forward: rows land in `form_field_bindings` with the correct
|
||||
* owner_type/owner_id + translated columns. Backward: JSON is
|
||||
* reconstructed into the source tables, table dropped.
|
||||
* - forward: rows land in form_field_bindings with the correct
|
||||
* owner_type/owner_id + translated columns; legacy JSON columns are
|
||||
* dropped afterwards.
|
||||
* - backward: the rollback pair genuinely reconstructs the JSON shape
|
||||
* before dropping the table.
|
||||
*
|
||||
* The "roll back both steps" contract is explicitly documented in
|
||||
* `2026_04_25_100001_drop_binding_json_columns.php`.
|
||||
*/
|
||||
final class FormFieldBindingMigrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const MIGRATION_PATH = 'database/migrations/2026_04_25_100000_create_form_field_bindings_table.php';
|
||||
|
||||
public function test_forward_migration_backfills_rows_from_both_json_sources(): void
|
||||
public function test_forward_migrations_backfill_rows_from_both_json_sources(): void
|
||||
{
|
||||
// Start from a clean slate where form_field_bindings does not exist.
|
||||
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
|
||||
// Roll back: drop_binding_json_columns → create_form_field_bindings.
|
||||
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
|
||||
$this->assertFalse(Schema::hasTable('form_field_bindings'));
|
||||
$this->assertTrue(Schema::hasColumn('form_fields', 'binding'));
|
||||
$this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding'));
|
||||
|
||||
[$fieldAId, $fieldCId, $fieldDId] = $this->seedFieldsWithBindingJson();
|
||||
[$libAId, $libCId] = $this->seedLibraryWithBindingJson();
|
||||
|
||||
$this->artisan('migrate', [
|
||||
'--path' => self::MIGRATION_PATH,
|
||||
'--realpath' => false,
|
||||
])->assertSuccessful();
|
||||
$this->artisan('migrate')->assertSuccessful();
|
||||
|
||||
$this->assertTrue(Schema::hasTable('form_field_bindings'));
|
||||
$this->assertFalse(Schema::hasColumn('form_fields', 'binding'));
|
||||
$this->assertFalse(Schema::hasColumn('form_field_library', 'default_binding'));
|
||||
|
||||
$rows = DB::table('form_field_bindings')->orderBy('owner_type')->orderBy('owner_id')->get();
|
||||
$rows = DB::table('form_field_bindings')->get();
|
||||
$this->assertCount(5, $rows, 'Expected 3 field + 2 library rows');
|
||||
|
||||
$fieldRowA = DB::table('form_field_bindings')
|
||||
->where('owner_type', 'form_field')
|
||||
->where('owner_id', $fieldAId)
|
||||
->first();
|
||||
$this->assertNotNull($fieldRowA);
|
||||
$this->assertSame('person', $fieldRowA->target_entity);
|
||||
$this->assertSame('email', $fieldRowA->target_attribute);
|
||||
$this->assertSame('entity_owned', $fieldRowA->mode);
|
||||
@@ -65,7 +67,6 @@ final class FormFieldBindingMigrationTest extends TestCase
|
||||
->where('owner_type', 'form_field')
|
||||
->where('owner_id', $fieldCId)
|
||||
->first();
|
||||
$this->assertNotNull($fieldRowC);
|
||||
$this->assertSame('mirrored', $fieldRowC->mode);
|
||||
$this->assertSame('write_on_submit', $fieldRowC->sync_direction);
|
||||
$this->assertSame('user_profile', $fieldRowC->target_entity);
|
||||
@@ -75,14 +76,12 @@ final class FormFieldBindingMigrationTest extends TestCase
|
||||
->where('owner_type', 'form_field')
|
||||
->where('owner_id', $fieldDId)
|
||||
->first();
|
||||
$this->assertNotNull($fieldRowD);
|
||||
$this->assertSame('entity_owned', $fieldRowD->mode);
|
||||
|
||||
$libRowA = DB::table('form_field_bindings')
|
||||
->where('owner_type', 'form_field_library')
|
||||
->where('owner_id', $libAId)
|
||||
->first();
|
||||
$this->assertNotNull($libRowA);
|
||||
$this->assertSame('person', $libRowA->target_entity);
|
||||
$this->assertSame('first_name', $libRowA->target_attribute);
|
||||
$this->assertSame('entity_owned', $libRowA->mode);
|
||||
@@ -91,46 +90,45 @@ final class FormFieldBindingMigrationTest extends TestCase
|
||||
->where('owner_type', 'form_field_library')
|
||||
->where('owner_id', $libCId)
|
||||
->first();
|
||||
$this->assertNotNull($libRowC);
|
||||
$this->assertSame('mirrored', $libRowC->mode);
|
||||
}
|
||||
|
||||
public function test_rollback_reconstructs_json_and_drops_table(): void
|
||||
{
|
||||
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
|
||||
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
|
||||
[$fieldAId, , ] = $this->seedFieldsWithBindingJson();
|
||||
[$libAId, ] = $this->seedLibraryWithBindingJson();
|
||||
|
||||
$this->artisan('migrate', [
|
||||
'--path' => self::MIGRATION_PATH,
|
||||
'--realpath' => false,
|
||||
])->assertSuccessful();
|
||||
$this->artisan('migrate')->assertSuccessful();
|
||||
|
||||
// Wipe the source JSON to prove the rollback writes back from rows.
|
||||
DB::table('form_fields')->where('id', $fieldAId)->update(['binding' => null]);
|
||||
DB::table('form_field_library')->where('id', $libAId)->update(['default_binding' => null]);
|
||||
// Fully-forward state: columns gone, rows in form_field_bindings.
|
||||
$this->assertFalse(Schema::hasColumn('form_fields', 'binding'));
|
||||
$this->assertSame(5, DB::table('form_field_bindings')->count());
|
||||
|
||||
// Step back over drop_binding_json_columns → columns reappear empty.
|
||||
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
|
||||
$this->assertTrue(Schema::hasColumn('form_fields', 'binding'));
|
||||
$this->assertNull(DB::table('form_fields')->where('id', $fieldAId)->value('binding'));
|
||||
|
||||
// Step back over create_form_field_bindings → JSON reconstructed.
|
||||
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
|
||||
$this->assertFalse(Schema::hasTable('form_field_bindings'));
|
||||
|
||||
$field = DB::table('form_fields')->where('id', $fieldAId)->first();
|
||||
$this->assertNotNull($field->binding);
|
||||
$json = json_decode((string) $field->binding, true);
|
||||
$this->assertSame([
|
||||
'mode' => 'entity_owned',
|
||||
'entity' => 'person',
|
||||
'column' => 'email',
|
||||
], $json);
|
||||
], json_decode((string) $field->binding, true));
|
||||
|
||||
$lib = DB::table('form_field_library')->where('id', $libAId)->first();
|
||||
$this->assertNotNull($lib->default_binding);
|
||||
$libJson = json_decode((string) $lib->default_binding, true);
|
||||
$this->assertSame([
|
||||
'mode' => 'entity_owned',
|
||||
'entity' => 'person',
|
||||
'column' => 'first_name',
|
||||
], $libJson);
|
||||
], json_decode((string) $lib->default_binding, true));
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string,2:string} */
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Bindings;
|
||||
|
||||
use App\Enums\FormBuilder\FormPurpose;
|
||||
use App\Exceptions\FormBuilder\PurposeRequirementsNotMetException;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use App\Services\FormBuilder\FormSchemaService;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The pre-publish check (`FormSchemaService::publish()`,
|
||||
* `PurposeRequirementsNotMetException`, ARCH §17.3) now reads from
|
||||
* `form_field_bindings`. External contract (purposeSlug +
|
||||
* missingBindings[]) unchanged.
|
||||
*/
|
||||
final class PublishChecksRelationalBindingsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Organisation $org;
|
||||
|
||||
private User $actor;
|
||||
|
||||
private FormSchemaService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(RoleSeeder::class);
|
||||
|
||||
$this->org = Organisation::factory()->create();
|
||||
$this->actor = User::factory()->create();
|
||||
$this->org->users()->attach($this->actor, ['role' => 'org_admin']);
|
||||
$this->actor->assignRole('org_admin');
|
||||
setPermissionsTeamId($this->org->id);
|
||||
|
||||
$this->service = $this->app->make(FormSchemaService::class);
|
||||
}
|
||||
|
||||
public function test_publish_succeeds_when_all_required_bindings_are_in_relational_table(): void
|
||||
{
|
||||
$schema = $this->service->create(
|
||||
$this->org,
|
||||
['name' => 'ER', 'purpose' => FormPurpose::EVENT_REGISTRATION->value],
|
||||
$this->actor,
|
||||
);
|
||||
|
||||
FormField::factory()->withEntityBinding('person', 'email')->create(['form_schema_id' => $schema->id]);
|
||||
FormField::factory()->withEntityBinding('person', 'first_name')->create(['form_schema_id' => $schema->id]);
|
||||
FormField::factory()->withEntityBinding('person', 'last_name')->create(['form_schema_id' => $schema->id]);
|
||||
|
||||
$published = $this->service->publish($schema->fresh(), $this->actor);
|
||||
|
||||
$this->assertTrue((bool) $published->is_published);
|
||||
}
|
||||
|
||||
public function test_publish_fails_when_required_binding_missing_reports_exact_paths(): void
|
||||
{
|
||||
$schema = $this->service->create(
|
||||
$this->org,
|
||||
['name' => 'ER-partial', 'purpose' => FormPurpose::EVENT_REGISTRATION->value],
|
||||
$this->actor,
|
||||
);
|
||||
FormField::factory()->withEntityBinding('person', 'email')->create(['form_schema_id' => $schema->id]);
|
||||
|
||||
try {
|
||||
$this->service->publish($schema->fresh(), $this->actor);
|
||||
$this->fail('Expected PurposeRequirementsNotMetException');
|
||||
} catch (PurposeRequirementsNotMetException $e) {
|
||||
$this->assertSame('event_registration', $e->purposeSlug);
|
||||
$this->assertSame(['person.first_name', 'person.last_name'], $e->missingBindings);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_publish_ignores_bindings_belonging_to_other_schemas(): void
|
||||
{
|
||||
$schemaA = $this->service->create(
|
||||
$this->org,
|
||||
['name' => 'A', 'purpose' => FormPurpose::SUPPLIER_INTAKE->value],
|
||||
$this->actor,
|
||||
);
|
||||
$schemaB = $this->service->create(
|
||||
$this->org,
|
||||
['name' => 'B', 'purpose' => FormPurpose::SUPPLIER_INTAKE->value],
|
||||
$this->actor,
|
||||
);
|
||||
|
||||
FormField::factory()->withEntityBinding('company', 'contact_email')->create(['form_schema_id' => $schemaB->id]);
|
||||
|
||||
try {
|
||||
$this->service->publish($schemaA->fresh(), $this->actor);
|
||||
$this->fail('Expected PurposeRequirementsNotMetException');
|
||||
} catch (PurposeRequirementsNotMetException $e) {
|
||||
$this->assertSame('supplier_intake', $e->purposeSlug);
|
||||
$this->assertSame(['company.name'], $e->missingBindings);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,6 @@ final class FormFieldApiTest extends TestCase
|
||||
Sanctum::actingAs($this->admin);
|
||||
$field = FormField::factory()->create([
|
||||
'form_schema_id' => $this->schema->id,
|
||||
'binding' => null,
|
||||
]);
|
||||
FormSubmission::factory()->create([
|
||||
'form_schema_id' => $this->schema->id,
|
||||
@@ -92,7 +91,6 @@ final class FormFieldApiTest extends TestCase
|
||||
Sanctum::actingAs($this->admin);
|
||||
$field = FormField::factory()->create([
|
||||
'form_schema_id' => $this->schema->id,
|
||||
'binding' => null,
|
||||
]);
|
||||
FormSubmission::factory()->create([
|
||||
'form_schema_id' => $this->schema->id,
|
||||
|
||||
@@ -148,12 +148,13 @@ final class PurposeSchemaLifecycleTest extends TestCase
|
||||
|
||||
private function addBindingField(FormSchema $schema, string $entity, string $column, string $slug): FormField
|
||||
{
|
||||
return FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'field_type' => FormFieldType::TEXT,
|
||||
'slug' => $slug,
|
||||
'label' => ucfirst($slug),
|
||||
'binding' => ['mode' => 'entity_owned', 'entity' => $entity, 'column' => $column],
|
||||
]);
|
||||
return FormField::factory()
|
||||
->withEntityBinding($entity, $column)
|
||||
->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'field_type' => FormFieldType::TEXT,
|
||||
'slug' => $slug,
|
||||
'label' => ucfirst($slug),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user