Files
crewli/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingServiceTest.php
bert.hausmans 6933e6d700 feat(form-builder): FormFieldBindingService + library-to-field row copy + snapshot writer
WS-5a commit 2 of 4.

FormFieldBindingService owns all writes to the relational binding table.
Validation against config/form_binding.php entity-column registry lives here
(ARCH §6.2).

FormFieldService::insertFromLibrary now calls copyBindings instead of
hydrating JSON — the Q3 row-copy mandate. Library and field bindings share
the same table; insertion is a row-clone operation.

Snapshot writer (FormSubmissionService::buildSnapshot) serialises bindings
via toJsonShape so schema_snapshot JSON keeps its ARCH §4.6.1 / §6.3
contract. No snapshot format change.
API resources source binding output from the relational table via the same
serialiser — external shape preserved.

Tests: service transactional behaviour, copyBindings preservation,
snapshot parity, API resource parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:48:47 +02:00

231 lines
8.4 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Bindings;
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
use App\Enums\FormBuilder\FormFieldBindingMode;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormFieldBinding;
use App\Models\FormBuilder\FormFieldLibrary;
use App\Models\FormBuilder\FormSchema;
use App\Models\Organisation;
use App\Services\FormBuilder\FormFieldBindingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
final class FormFieldBindingServiceTest extends TestCase
{
use RefreshDatabase;
private FormFieldBindingService $service;
private Organisation $org;
private FormSchema $schema;
protected function setUp(): void
{
parent::setUp();
$this->service = $this->app->make(FormFieldBindingService::class);
$this->org = Organisation::factory()->create();
$this->schema = FormSchema::factory()->create(['organisation_id' => $this->org->id]);
}
public function test_replace_bindings_is_transactional_and_swaps_old_for_new(): void
{
$field = FormField::factory()->create(['form_schema_id' => $this->schema->id]);
FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create();
$this->service->replaceBindings($field, [[
'target_entity' => 'person',
'target_attribute' => 'first_name',
'mode' => FormFieldBindingMode::EntityOwned->value,
]]);
$rows = FormFieldBinding::query()
->withoutGlobalScopes()
->where('owner_type', 'form_field')
->where('owner_id', $field->id)
->get();
$this->assertCount(1, $rows);
$this->assertSame('first_name', $rows->first()->target_attribute);
}
public function test_replace_bindings_with_empty_array_clears_all(): void
{
$field = FormField::factory()->create(['form_schema_id' => $this->schema->id]);
FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create();
$this->service->replaceBindings($field, []);
$this->assertSame(0, FormFieldBinding::query()
->withoutGlobalScopes()
->where('owner_type', 'form_field')
->where('owner_id', $field->id)
->count(),
);
}
public function test_replace_bindings_rejects_unregistered_target_entity(): void
{
$field = FormField::factory()->create(['form_schema_id' => $this->schema->id]);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("target_entity 'unicorn' is not registered");
$this->service->replaceBindings($field, [[
'target_entity' => 'unicorn',
'target_attribute' => 'email',
'mode' => FormFieldBindingMode::EntityOwned->value,
]]);
}
public function test_replace_bindings_rejects_unregistered_target_attribute(): void
{
$field = FormField::factory()->create(['form_schema_id' => $this->schema->id]);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("target_attribute 'person.made_up_column'");
$this->service->replaceBindings($field, [[
'target_entity' => 'person',
'target_attribute' => 'made_up_column',
'mode' => FormFieldBindingMode::EntityOwned->value,
]]);
}
public function test_replace_bindings_rejects_invalid_mode(): void
{
$field = FormField::factory()->create(['form_schema_id' => $this->schema->id]);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("mode 'form_owned'");
$this->service->replaceBindings($field, [[
'target_entity' => 'person',
'target_attribute' => 'email',
'mode' => 'form_owned',
]]);
}
public function test_replace_bindings_logs_activity_on_change(): void
{
Activity::query()->delete();
$field = FormField::factory()->create(['form_schema_id' => $this->schema->id]);
$this->service->replaceBindings($field, [[
'target_entity' => 'person',
'target_attribute' => 'email',
'mode' => FormFieldBindingMode::EntityOwned->value,
]]);
$entry = Activity::query()
->where('subject_type', $field->getMorphClass())
->where('subject_id', $field->id)
->where('description', 'field.bindings_replaced')
->first();
$this->assertNotNull($entry);
}
public function test_copy_bindings_preserves_every_column(): void
{
$library = FormFieldLibrary::factory()->create(['organisation_id' => $this->org->id]);
$source = FormFieldBinding::factory()->forLibrary($library)->create([
'target_entity' => 'user_profile',
'target_attribute' => 'bio',
'mode' => FormFieldBindingMode::Mirrored->value,
'sync_direction' => 'write_on_submit',
'merge_strategy' => FormFieldBindingMergeStrategy::Append->value,
'trust_level' => 80,
'is_identity_key' => true,
]);
$field = FormField::factory()->create(['form_schema_id' => $this->schema->id]);
$this->service->copyBindings($library->fresh(), $field);
$copy = FormFieldBinding::query()
->withoutGlobalScopes()
->where('owner_type', 'form_field')
->where('owner_id', $field->id)
->first();
$this->assertNotNull($copy);
$this->assertSame($source->target_entity, $copy->target_entity);
$this->assertSame($source->target_attribute, $copy->target_attribute);
$this->assertSame(FormFieldBindingMode::Mirrored, $copy->mode);
$this->assertSame('write_on_submit', $copy->sync_direction);
$this->assertSame(FormFieldBindingMergeStrategy::Append, $copy->merge_strategy);
$this->assertSame(80, $copy->trust_level);
$this->assertTrue($copy->is_identity_key);
}
public function test_copy_bindings_is_noop_when_source_has_none(): void
{
$library = FormFieldLibrary::factory()->create(['organisation_id' => $this->org->id]);
$field = FormField::factory()->create(['form_schema_id' => $this->schema->id]);
$this->service->copyBindings($library, $field);
$this->assertSame(0, FormFieldBinding::query()
->withoutGlobalScopes()
->where('owner_type', 'form_field')
->where('owner_id', $field->id)
->count(),
);
}
public function test_to_json_shape_matches_arch_6_3_for_entity_owned(): void
{
$field = FormField::factory()->create(['form_schema_id' => $this->schema->id]);
$binding = FormFieldBinding::factory()
->forField($field)
->entityOwned('person', 'email')
->create();
$this->assertSame([
'mode' => 'entity_owned',
'entity' => 'person',
'column' => 'email',
], $this->service->toJsonShape($binding));
}
public function test_to_json_shape_matches_arch_6_3_for_mirrored(): void
{
$field = FormField::factory()->create(['form_schema_id' => $this->schema->id]);
$binding = FormFieldBinding::factory()
->forField($field)
->mirrored('user_profile', 'emergency_contact_name')
->create();
$this->assertSame([
'mode' => 'mirrored',
'entity' => 'user_profile',
'column' => 'emergency_contact_name',
'sync_direction' => 'write_on_submit',
], $this->service->toJsonShape($binding));
}
public function test_to_json_shape_returns_null_for_no_binding(): void
{
$this->assertNull($this->service->toJsonShape(null));
}
public function test_bindings_for_returns_only_owner_bindings(): void
{
$field = FormField::factory()->create(['form_schema_id' => $this->schema->id]);
$other = FormField::factory()->create(['form_schema_id' => $this->schema->id]);
FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create();
FormFieldBinding::factory()->forField($field)->entityOwned('person', 'phone')->create();
FormFieldBinding::factory()->forField($other)->entityOwned('person', 'first_name')->create();
$this->assertCount(2, $this->service->bindingsFor($field));
}
}