diff --git a/api/app/Console/Commands/VerifyFormsDataIntegrity.php b/api/app/Console/Commands/VerifyFormsDataIntegrity.php index 9fde1dbe..6a540078 100644 --- a/api/app/Console/Commands/VerifyFormsDataIntegrity.php +++ b/api/app/Console/Commands/VerifyFormsDataIntegrity.php @@ -128,28 +128,24 @@ final class VerifyFormsDataIntegrity extends Command ->havingRaw('COUNT(*) > 1') ->count(); - // Binding registry cross-check - $binding = (array) config('form_binding', []); + // Binding registry cross-check (WS-5a: relational form_field_bindings). + $registry = (array) config('form_binding', []); $badBindings = 0; - $invalidBindings = DB::table('form_fields')->whereNotNull('binding')->select('binding')->get(); - foreach ($invalidBindings as $row) { - $b = is_string($row->binding) ? json_decode($row->binding, true) : null; - if (! is_array($b) || ! isset($b['mode'], $b['entity'], $b['column'])) { + $bindingRows = DB::table('form_field_bindings') + ->select(['mode', 'target_entity', 'target_attribute']) + ->get(); + foreach ($bindingRows as $row) { + if (! in_array($row->mode, ['entity_owned', 'mirrored'], true)) { $badBindings++; continue; } - if (! in_array($b['mode'], ['entity_owned', 'mirrored'], true)) { + if (! isset($registry[$row->target_entity][$row->target_attribute])) { $badBindings++; continue; } - if (! isset($binding[$b['entity']][$b['column']])) { - $badBindings++; - - continue; - } - if (($binding[$b['entity']][$b['column']]['writable'] ?? false) !== true) { + if (($registry[$row->target_entity][$row->target_attribute]['writable'] ?? false) !== true) { $badBindings++; } } diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldLibraryController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldLibraryController.php index 35445199..dbdb55e0 100644 --- a/api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldLibraryController.php +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldLibraryController.php @@ -10,6 +10,7 @@ use App\Http\Requests\Api\V1\FormBuilder\UpdateFormFieldLibraryRequest; use App\Http\Resources\FormBuilder\FormFieldLibraryResource; use App\Models\FormBuilder\FormFieldLibrary; use App\Models\Organisation; +use App\Services\FormBuilder\FormFieldBindingService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Support\Facades\Gate; @@ -17,6 +18,10 @@ use Illuminate\Support\Str; final class FormFieldLibraryController extends Controller { + public function __construct( + private readonly FormFieldBindingService $bindingService, + ) {} + public function index(Organisation $organisation): AnonymousResourceCollection { Gate::authorize('viewAny', [FormFieldLibrary::class, $organisation]); @@ -42,6 +47,7 @@ final class FormFieldLibraryController extends Controller Gate::authorize('create', [FormFieldLibrary::class, $organisation]); $data = $request->validated(); + $bindingSpec = $this->extractBindingSpec($data); $data['organisation_id'] = $organisation->id; $data['is_system'] = false; $data['is_active'] ??= true; @@ -50,6 +56,10 @@ final class FormFieldLibraryController extends Controller /** @var FormFieldLibrary $library */ $library = FormFieldLibrary::create($data); + if ($bindingSpec !== null) { + $this->bindingService->replaceBindings($library, [$bindingSpec]); + } + return $this->created(new FormFieldLibraryResource($library)); } @@ -58,12 +68,46 @@ final class FormFieldLibraryController extends Controller $this->assertSameOrg($organisation, $fieldLibrary); Gate::authorize('update', $fieldLibrary); - $fieldLibrary->fill($request->validated()); + $data = $request->validated(); + $bindingProvided = array_key_exists('default_binding', $data); + $bindingSpec = $bindingProvided ? $this->extractBindingSpec($data) : null; + + $fieldLibrary->fill($data); $fieldLibrary->save(); + if ($bindingProvided) { + $this->bindingService->replaceBindings( + $fieldLibrary, + $bindingSpec === null ? [] : [$bindingSpec], + ); + } + return $this->success(new FormFieldLibraryResource($fieldLibrary)); } + /** + * @param array $data + * @return array{target_entity:string,target_attribute:string,mode:string,sync_direction?:?string}|null + */ + private function extractBindingSpec(array &$data): ?array + { + if (! array_key_exists('default_binding', $data)) { + return null; + } + $raw = $data['default_binding']; + unset($data['default_binding']); + if (! is_array($raw) || $raw === []) { + return null; + } + + return [ + 'target_entity' => (string) ($raw['entity'] ?? ''), + 'target_attribute' => (string) ($raw['column'] ?? ''), + 'mode' => (string) ($raw['mode'] ?? ''), + 'sync_direction' => isset($raw['sync_direction']) ? (string) $raw['sync_direction'] : null, + ]; + } + public function destroy(Organisation $organisation, FormFieldLibrary $fieldLibrary): JsonResponse { $this->assertSameOrg($organisation, $fieldLibrary); diff --git a/api/app/Models/FormBuilder/FormField.php b/api/app/Models/FormBuilder/FormField.php index 20c319f9..513b2de4 100644 --- a/api/app/Models/FormBuilder/FormField.php +++ b/api/app/Models/FormBuilder/FormField.php @@ -59,7 +59,6 @@ final class FormField extends Model 'is_unique', 'is_pii', 'display_width', - 'binding', 'conditional_logic', 'role_restrictions', 'translations', @@ -72,7 +71,6 @@ final class FormField extends Model protected $casts = [ 'options' => 'array', 'validation_rules' => 'array', - 'binding' => 'array', 'conditional_logic' => 'array', 'role_restrictions' => 'array', 'translations' => 'array', diff --git a/api/app/Models/FormBuilder/FormFieldLibrary.php b/api/app/Models/FormBuilder/FormFieldLibrary.php index b0362747..a23e31c1 100644 --- a/api/app/Models/FormBuilder/FormFieldLibrary.php +++ b/api/app/Models/FormBuilder/FormFieldLibrary.php @@ -38,7 +38,6 @@ final class FormFieldLibrary extends Model 'validation_rules', 'default_is_required', 'default_is_filterable', - 'default_binding', 'translations', 'description', 'is_active', @@ -48,7 +47,6 @@ final class FormFieldLibrary extends Model protected $casts = [ 'options' => 'array', 'validation_rules' => 'array', - 'default_binding' => 'array', 'translations' => 'array', 'default_is_required' => 'bool', 'default_is_filterable' => 'bool', diff --git a/api/app/Services/FormBuilder/FormFieldService.php b/api/app/Services/FormBuilder/FormFieldService.php index 7f677cc1..53128b36 100644 --- a/api/app/Services/FormBuilder/FormFieldService.php +++ b/api/app/Services/FormBuilder/FormFieldService.php @@ -36,11 +36,17 @@ final class FormFieldService $data['form_schema_id'] = $schema->id; $data['sort_order'] ??= $this->nextSortOrder($schema); + $bindingSpec = $this->extractBindingSpec($data); + $this->assertNoConditionalCycle($schema, null, $data['conditional_logic'] ?? null, $data['slug'] ?? null); /** @var FormField $field */ $field = FormField::create($data); + if ($bindingSpec !== null) { + $this->bindingService->replaceBindings($field, [$bindingSpec]); + } + $this->schemaService->bumpVersion($schema); $field->logFieldChange('field.created'); @@ -56,7 +62,13 @@ final class FormFieldService $schema = $field->schema; $this->assertNotFrozenForStructural($schema, $data); - if (array_key_exists('binding', $data) && $data['binding'] !== $field->binding) { + $bindingProvided = array_key_exists('binding', $data); + $rawBinding = $bindingProvided ? $data['binding'] : null; + $bindingSpec = $bindingProvided ? $this->extractBindingSpec($data) : null; + + $currentBindingShape = $this->bindingService->toJsonShape($field->bindings()->first()); + + if ($bindingProvided && $this->bindingChanged($currentBindingShape, $rawBinding)) { $this->assertBindingChangeAllowed($field, $forceBindingChange); } @@ -65,7 +77,7 @@ final class FormFieldService } $before = [ - 'binding' => $field->binding, + 'binding' => $currentBindingShape, 'is_filterable' => $field->is_filterable, 'is_pii' => $field->is_pii, 'field_type' => $field->field_type, @@ -74,12 +86,16 @@ final class FormFieldService $field->fill($data); $field->save(); + if ($bindingProvided) { + $this->bindingService->replaceBindings($field, $bindingSpec === null ? [] : [$bindingSpec]); + } + $this->schemaService->bumpVersion($schema); $field->logFieldChange('field.updated', [ 'old' => $before, 'new' => [ - 'binding' => $field->binding, + 'binding' => $this->bindingService->toJsonShape($field->bindings()->first()), 'is_filterable' => $field->is_filterable, 'is_pii' => $field->is_pii, 'field_type' => $field->field_type, @@ -93,6 +109,51 @@ final class FormFieldService return $field->refresh(); } + /** + * @param array $data + * @return array{target_entity:string,target_attribute:string,mode:string,sync_direction?:?string}|null + */ + private function extractBindingSpec(array &$data): ?array + { + if (! array_key_exists('binding', $data)) { + return null; + } + $raw = $data['binding']; + unset($data['binding']); + if (! is_array($raw) || $raw === []) { + return null; + } + + return [ + 'target_entity' => (string) ($raw['entity'] ?? ''), + 'target_attribute' => (string) ($raw['column'] ?? ''), + 'mode' => (string) ($raw['mode'] ?? ''), + 'sync_direction' => isset($raw['sync_direction']) ? (string) $raw['sync_direction'] : null, + ]; + } + + /** + * @param array|null $current Pre-WS-5a ARCH §6.3 shape + * @param array|null $next + */ + private function bindingChanged(?array $current, ?array $next): bool + { + $normalise = static function (?array $value): array { + if ($value === null || $value === []) { + return []; + } + + return [ + 'mode' => (string) ($value['mode'] ?? ''), + 'entity' => (string) ($value['entity'] ?? ''), + 'column' => (string) ($value['column'] ?? ''), + 'sync_direction' => (string) ($value['sync_direction'] ?? ''), + ]; + }; + + return $normalise($current) !== $normalise($next); + } + public function delete(FormField $field, ?string $confirmedName = null): void { $schema = $field->schema; diff --git a/api/app/Services/FormBuilder/FormSchemaService.php b/api/app/Services/FormBuilder/FormSchemaService.php index 47d7a520..005ccae6 100644 --- a/api/app/Services/FormBuilder/FormSchemaService.php +++ b/api/app/Services/FormBuilder/FormSchemaService.php @@ -11,6 +11,7 @@ use App\Exceptions\FormBuilder\EditLockConflictException; use App\Exceptions\FormBuilder\PurposeRequirementsNotMetException; use App\FormBuilder\Purposes\PurposeRegistry; use App\Models\FormBuilder\FormField; +use App\Models\FormBuilder\FormFieldBinding; use App\Models\FormBuilder\FormSchema; use App\Models\FormBuilder\FormSchemaSection; use App\Models\FormBuilder\FormSubmission; @@ -133,8 +134,8 @@ final class FormSchemaService * purpose is bound by at least one field on the schema. * * Binding paths follow Pattern A notation (`{entity}.{attribute}`). - * In v1.0 we read `form_fields.binding` JSON; WS-5a will switch this - * to the relational `form_field_bindings` table. + * Sourced from the relational `form_field_bindings` table (WS-5a; + * ARCH §6.7, §17.3). */ private function assertRequiredBindingsPresent(FormSchema $schema): void { @@ -151,19 +152,16 @@ final class FormSchemaService return; } - $bound = []; - foreach ($schema->fields as $field) { - $binding = $field->binding; - if (! is_array($binding)) { - continue; - } - $entity = (string) ($binding['entity'] ?? ''); - $column = (string) ($binding['column'] ?? ''); - if ($entity === '' || $column === '') { - continue; - } - $bound[] = $entity.'.'.$column; - } + $fieldIds = $schema->fields()->pluck('id'); + + $bound = FormFieldBinding::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->whereIn('owner_id', $fieldIds) + ->get(['target_entity', 'target_attribute']) + ->map(fn (FormFieldBinding $b) => $b->target_entity.'.'.$b->target_attribute) + ->unique() + ->all(); $missing = array_values(array_diff($required, $bound)); if ($missing !== []) { diff --git a/api/app/Services/FormBuilder/FormValueService.php b/api/app/Services/FormBuilder/FormValueService.php index ba5c66c3..9dbb80c7 100644 --- a/api/app/Services/FormBuilder/FormValueService.php +++ b/api/app/Services/FormBuilder/FormValueService.php @@ -291,18 +291,13 @@ final class FormValueService private function writeEntityMirror(FormSubmission $submission, FormField $field, mixed $raw): void { - $binding = $field->binding; - if (! is_array($binding) || ($binding['mode'] ?? null) === null) { + $binding = $field->bindings()->first(); + if ($binding === null) { return; } - $mode = (string) $binding['mode']; - if (! in_array($mode, ['entity_owned', 'mirrored'], true)) { - return; - } - - $entity = (string) ($binding['entity'] ?? ''); - $column = (string) ($binding['column'] ?? ''); + $entity = (string) $binding->target_entity; + $column = (string) $binding->target_attribute; if ($entity === '' || $column === '') { return; } diff --git a/api/config/form_binding.php b/api/config/form_binding.php index 02a1d248..3c2f1cef 100644 --- a/api/config/form_binding.php +++ b/api/config/form_binding.php @@ -35,6 +35,7 @@ return [ ], 'company' => [ + 'name' => ['type' => 'string', 'label' => 'Bedrijfsnaam', 'writable' => true], 'contact_first_name' => ['type' => 'string', 'label' => 'Contact voornaam', 'writable' => true], 'contact_last_name' => ['type' => 'string', 'label' => 'Contact achternaam', 'writable' => true], 'contact_email' => ['type' => 'string', 'label' => 'Contact e-mail', 'writable' => true], diff --git a/api/database/factories/FormBuilder/FormFieldFactory.php b/api/database/factories/FormBuilder/FormFieldFactory.php index d40bc38e..206dd4b4 100644 --- a/api/database/factories/FormBuilder/FormFieldFactory.php +++ b/api/database/factories/FormBuilder/FormFieldFactory.php @@ -53,7 +53,6 @@ final class FormFieldFactory extends Factory 'is_unique' => false, 'is_pii' => false, 'display_width' => FormFieldDisplayWidth::FULL, - 'binding' => null, 'conditional_logic' => null, 'role_restrictions' => null, 'translations' => null, diff --git a/api/database/factories/FormBuilder/FormFieldLibraryFactory.php b/api/database/factories/FormBuilder/FormFieldLibraryFactory.php index ae6720ab..b6da4b3e 100644 --- a/api/database/factories/FormBuilder/FormFieldLibraryFactory.php +++ b/api/database/factories/FormBuilder/FormFieldLibraryFactory.php @@ -36,7 +36,6 @@ final class FormFieldLibraryFactory extends Factory 'validation_rules' => null, 'default_is_required' => false, 'default_is_filterable' => false, - 'default_binding' => null, 'translations' => null, 'description' => fake('nl_NL')->sentence(), 'is_active' => true, diff --git a/api/database/migrations/2026_04_25_100001_drop_binding_json_columns.php b/api/database/migrations/2026_04_25_100001_drop_binding_json_columns.php new file mode 100644 index 00000000..d1afb5cd --- /dev/null +++ b/api/database/migrations/2026_04_25_100001_drop_binding_json_columns.php @@ -0,0 +1,54 @@ +dropColumn('binding'); + }); + } + + if (Schema::hasColumn('form_field_library', 'default_binding')) { + Schema::table('form_field_library', function (Blueprint $table): void { + $table->dropColumn('default_binding'); + }); + } + } + + public function down(): void + { + if (! Schema::hasColumn('form_fields', 'binding')) { + Schema::table('form_fields', function (Blueprint $table): void { + $table->json('binding')->nullable()->after('display_width'); + }); + } + + if (! Schema::hasColumn('form_field_library', 'default_binding')) { + Schema::table('form_field_library', function (Blueprint $table): void { + $table->json('default_binding')->nullable()->after('default_is_filterable'); + }); + } + } +}; diff --git a/api/database/seeders/FormBuilderDevSeeder.php b/api/database/seeders/FormBuilderDevSeeder.php index 3fadc6d2..908820b2 100644 --- a/api/database/seeders/FormBuilderDevSeeder.php +++ b/api/database/seeders/FormBuilderDevSeeder.php @@ -99,7 +99,7 @@ final class FormBuilderDevSeeder 'is_required' => $field['is_required'] ?? false, 'is_filterable' => $field['is_filterable'] ?? false, 'is_pii' => $field['is_pii'] ?? false, - 'binding' => null, + 'binding' => null, // Pattern B — snapshot embeds null for form-owned fields. 'conditional_logic' => null, 'translations' => null, 'value_storage_hint' => $field['type']->recommendedValueStorageHint()->value, @@ -372,7 +372,6 @@ final class FormBuilderDevSeeder 'is_admin_only' => false, 'is_pii' => false, 'display_width' => $def['display_width'] ?? 'full', - 'binding' => null, 'role_restrictions' => null, 'value_storage_hint' => $def['value_storage_hint'] ?? FormValueStorageHint::JSON, 'sort_order' => $sortOrder + 1, diff --git a/api/tests/Feature/FormBuilder/Bindings/BindingJsonColumnsDroppedTest.php b/api/tests/Feature/FormBuilder/Bindings/BindingJsonColumnsDroppedTest.php new file mode 100644 index 00000000..91ab9af4 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/BindingJsonColumnsDroppedTest.php @@ -0,0 +1,24 @@ +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')); + } +} diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index 235ceb43..865a2e45 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -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} */ diff --git a/api/tests/Feature/FormBuilder/Bindings/PublishChecksRelationalBindingsTest.php b/api/tests/Feature/FormBuilder/Bindings/PublishChecksRelationalBindingsTest.php new file mode 100644 index 00000000..508d62ee --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/PublishChecksRelationalBindingsTest.php @@ -0,0 +1,106 @@ +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); + } + } +} diff --git a/api/tests/Feature/FormBuilder/FormFieldApiTest.php b/api/tests/Feature/FormBuilder/FormFieldApiTest.php index 70215205..ae5214c4 100644 --- a/api/tests/Feature/FormBuilder/FormFieldApiTest.php +++ b/api/tests/Feature/FormBuilder/FormFieldApiTest.php @@ -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, diff --git a/api/tests/Feature/FormBuilder/Purposes/PurposeSchemaLifecycleTest.php b/api/tests/Feature/FormBuilder/Purposes/PurposeSchemaLifecycleTest.php index 1224cc59..4472ac60 100644 --- a/api/tests/Feature/FormBuilder/Purposes/PurposeSchemaLifecycleTest.php +++ b/api/tests/Feature/FormBuilder/Purposes/PurposeSchemaLifecycleTest.php @@ -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), + ]); } } diff --git a/api/tests/Unit/Models/FormBuilder/FormFieldTest.php b/api/tests/Unit/Models/FormBuilder/FormFieldTest.php index 7029a76c..1f3ba099 100644 --- a/api/tests/Unit/Models/FormBuilder/FormFieldTest.php +++ b/api/tests/Unit/Models/FormBuilder/FormFieldTest.php @@ -32,20 +32,29 @@ final class FormFieldTest extends TestCase $this->assertIsString($field->fresh()->field_type); } - public function test_form_field_casts_binding_and_translations_to_array(): void + public function test_form_field_casts_translations_to_array(): void { $field = FormField::factory()->create([ - 'binding' => ['mode' => 'entity_owned', 'entity' => 'person', 'column' => 'first_name'], 'translations' => ['en' => ['label' => 'First name']], ]); $fresh = $field->fresh(); - $this->assertIsArray($fresh->binding); - $this->assertSame('entity_owned', $fresh->binding['mode']); $this->assertIsArray($fresh->translations); $this->assertSame('First name', $fresh->translations['en']['label']); } + public function test_form_field_bindings_relation_exposes_relational_rows(): void + { + $field = FormField::factory() + ->withEntityBinding('person', 'first_name') + ->create(); + + $binding = $field->bindings()->first(); + $this->assertNotNull($binding); + $this->assertSame('person', $binding->target_entity); + $this->assertSame('first_name', $binding->target_attribute); + } + public function test_form_field_has_many_values(): void { $schema = FormSchema::factory()->create();