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>
This commit is contained in:
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Resources\FormBuilder;
|
||||
|
||||
use App\Models\FormBuilder\FormFieldLibrary;
|
||||
use App\Services\FormBuilder\FormFieldBindingService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -30,7 +31,9 @@ final class FormFieldLibraryResource extends JsonResource
|
||||
'validation_rules' => $this->validation_rules,
|
||||
'default_is_required' => (bool) $this->default_is_required,
|
||||
'default_is_filterable' => (bool) $this->default_is_filterable,
|
||||
'default_binding' => $this->default_binding,
|
||||
'default_binding' => app(FormFieldBindingService::class)->toJsonShape(
|
||||
$this->resource->bindings->first(),
|
||||
),
|
||||
'translations' => $this->translations,
|
||||
'description' => $this->description,
|
||||
'usage_count' => (int) ($this->usage_count ?? 0),
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Resources\FormBuilder;
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\PersonTag;
|
||||
use App\Services\FormBuilder\FormFieldBindingService;
|
||||
use App\Services\FormBuilder\FormLocaleResolver;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
@@ -49,7 +50,9 @@ final class FormFieldResource extends JsonResource
|
||||
'is_unique' => (bool) $this->is_unique,
|
||||
'is_pii' => (bool) $this->is_pii,
|
||||
'display_width' => $this->display_width instanceof \BackedEnum ? $this->display_width->value : $this->display_width,
|
||||
'binding' => $this->binding,
|
||||
'binding' => app(FormFieldBindingService::class)->toJsonShape(
|
||||
$this->resource->bindings->first(),
|
||||
),
|
||||
'conditional_logic' => $this->conditional_logic,
|
||||
'role_restrictions' => $this->role_restrictions,
|
||||
'translations' => $this->translations,
|
||||
|
||||
199
api/app/Services/FormBuilder/FormFieldBindingService.php
Normal file
199
api/app/Services/FormBuilder/FormFieldBindingService.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\FormBuilder;
|
||||
|
||||
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 Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Owns all writes to `form_field_bindings`. Single source of truth for:
|
||||
* - entity-column registry validation (config/form_binding.php, ARCH §6.2)
|
||||
* - library → field row-copy on insertFromLibrary (Q3 row-copy mandate)
|
||||
* - serialisation of rows into the ARCH §6.3 JSON shape (for snapshot
|
||||
* embedding and API resource output)
|
||||
*
|
||||
* Pattern B (no binding) is represented by the absence of a row — callers
|
||||
* pass an empty array to replaceBindings() to clear bindings.
|
||||
*
|
||||
* Danger guards (pre-existing in FormFieldService::update for owner=field)
|
||||
* sit above this service; replaceBindings() trusts the guard upstream and
|
||||
* only enforces registry validation + transactional write.
|
||||
*/
|
||||
final class FormFieldBindingService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, FormFieldBinding>
|
||||
*/
|
||||
public function bindingsFor(FormField|FormFieldLibrary $owner): Collection
|
||||
{
|
||||
$type = $this->ownerTypeFor($owner);
|
||||
|
||||
return FormFieldBinding::query()
|
||||
->where('owner_type', $type)
|
||||
->where('owner_id', $owner->getKey())
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the set of bindings on an owner transactionally. Callers pass
|
||||
* an array of binding specs; each spec is validated against the entity-
|
||||
* column registry before anything is written. An empty array clears all
|
||||
* bindings for the owner (Pattern B).
|
||||
*
|
||||
* @param list<array{target_entity:string,target_attribute:string,mode:string,sync_direction?:?string,merge_strategy?:string,trust_level?:int,is_identity_key?:bool}> $bindingData
|
||||
*/
|
||||
public function replaceBindings(FormField|FormFieldLibrary $owner, array $bindingData): void
|
||||
{
|
||||
foreach ($bindingData as $spec) {
|
||||
$this->assertSpecValid($spec);
|
||||
}
|
||||
|
||||
$ownerType = $this->ownerTypeFor($owner);
|
||||
|
||||
DB::transaction(function () use ($owner, $ownerType, $bindingData): void {
|
||||
FormFieldBinding::query()
|
||||
->withoutGlobalScopes()
|
||||
->where('owner_type', $ownerType)
|
||||
->where('owner_id', $owner->getKey())
|
||||
->delete();
|
||||
|
||||
foreach ($bindingData as $spec) {
|
||||
FormFieldBinding::query()->withoutGlobalScopes()->create([
|
||||
'owner_type' => $ownerType,
|
||||
'owner_id' => $owner->getKey(),
|
||||
'target_entity' => $spec['target_entity'],
|
||||
'target_attribute' => $spec['target_attribute'],
|
||||
'mode' => $spec['mode'],
|
||||
'sync_direction' => $spec['sync_direction'] ?? null,
|
||||
'merge_strategy' => $spec['merge_strategy']
|
||||
?? FormFieldBindingMergeStrategy::Overwrite->value,
|
||||
'trust_level' => $spec['trust_level'] ?? 50,
|
||||
'is_identity_key' => $spec['is_identity_key'] ?? false,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($owner instanceof FormField) {
|
||||
$owner->logFieldChange('field.bindings_replaced', [
|
||||
'count' => count($bindingData),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Row-copy from a library entry to a freshly-inserted field (ARCH §6.7,
|
||||
* addendum Q3). Preserves every binding column; only owner_type /
|
||||
* owner_id are rewritten.
|
||||
*/
|
||||
public function copyBindings(FormFieldLibrary $from, FormField $to): void
|
||||
{
|
||||
$bindings = $this->bindingsFor($from);
|
||||
|
||||
if ($bindings->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($bindings, $to): void {
|
||||
foreach ($bindings as $binding) {
|
||||
FormFieldBinding::query()->withoutGlobalScopes()->create([
|
||||
'owner_type' => 'form_field',
|
||||
'owner_id' => $to->id,
|
||||
'target_entity' => $binding->target_entity,
|
||||
'target_attribute' => $binding->target_attribute,
|
||||
'mode' => $binding->mode instanceof FormFieldBindingMode
|
||||
? $binding->mode->value
|
||||
: (string) $binding->mode,
|
||||
'sync_direction' => $binding->sync_direction,
|
||||
'merge_strategy' => $binding->merge_strategy instanceof FormFieldBindingMergeStrategy
|
||||
? $binding->merge_strategy->value
|
||||
: (string) $binding->merge_strategy,
|
||||
'trust_level' => (int) $binding->trust_level,
|
||||
'is_identity_key' => (bool) $binding->is_identity_key,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise a binding row into the ARCH §6.3 JSON shape. Returned null
|
||||
* if no binding is given — callers can pipe directly into snapshot /
|
||||
* resource output (Pattern B = null).
|
||||
*
|
||||
* @return array{mode:string,entity:string,column:string,sync_direction?:string}|null
|
||||
*/
|
||||
public function toJsonShape(?FormFieldBinding $binding): ?array
|
||||
{
|
||||
if ($binding === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mode = $binding->mode instanceof FormFieldBindingMode
|
||||
? $binding->mode->value
|
||||
: (string) $binding->mode;
|
||||
|
||||
$shape = [
|
||||
'mode' => $mode,
|
||||
'entity' => $binding->target_entity,
|
||||
'column' => $binding->target_attribute,
|
||||
];
|
||||
if ($binding->sync_direction !== null && $binding->sync_direction !== '') {
|
||||
$shape['sync_direction'] = $binding->sync_direction;
|
||||
}
|
||||
|
||||
return $shape;
|
||||
}
|
||||
|
||||
private function ownerTypeFor(FormField|FormFieldLibrary $owner): string
|
||||
{
|
||||
return $owner instanceof FormField ? 'form_field' : 'form_field_library';
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $spec */
|
||||
private function assertSpecValid(array $spec): void
|
||||
{
|
||||
$entity = (string) ($spec['target_entity'] ?? '');
|
||||
$attribute = (string) ($spec['target_attribute'] ?? '');
|
||||
$mode = (string) ($spec['mode'] ?? '');
|
||||
|
||||
if ($entity === '' || $attribute === '') {
|
||||
throw new \InvalidArgumentException(
|
||||
'Binding spec requires target_entity and target_attribute.',
|
||||
);
|
||||
}
|
||||
|
||||
if (FormFieldBindingMode::tryFrom($mode) === null) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Binding spec mode '{$mode}' is not a valid FormFieldBindingMode.",
|
||||
);
|
||||
}
|
||||
|
||||
if (array_key_exists('merge_strategy', $spec)) {
|
||||
$strategy = (string) $spec['merge_strategy'];
|
||||
if (FormFieldBindingMergeStrategy::tryFrom($strategy) === null) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Binding spec merge_strategy '{$strategy}' is not a valid FormFieldBindingMergeStrategy.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$registry = (array) config('form_binding.'.$entity);
|
||||
if ($registry === []) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Binding target_entity '{$entity}' is not registered in config/form_binding.php.",
|
||||
);
|
||||
}
|
||||
|
||||
if (! array_key_exists($attribute, $registry)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Binding target_attribute '{$entity}.{$attribute}' is not registered in config/form_binding.php.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ final class FormFieldService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FormSchemaService $schemaService,
|
||||
private readonly FormFieldBindingService $bindingService,
|
||||
) {}
|
||||
|
||||
public function create(FormSchema $schema, array $data): FormField
|
||||
@@ -140,7 +141,6 @@ final class FormFieldService
|
||||
'validation_rules' => $library->validation_rules,
|
||||
'is_required' => (bool) $library->default_is_required,
|
||||
'is_filterable' => (bool) $library->default_is_filterable,
|
||||
'binding' => $library->default_binding,
|
||||
'translations' => $library->translations,
|
||||
'sort_order' => $this->nextSortOrder($schema),
|
||||
], $overrides);
|
||||
@@ -154,6 +154,8 @@ final class FormFieldService
|
||||
/** @var FormField $field */
|
||||
$field = FormField::create($data);
|
||||
|
||||
$this->bindingService->copyBindings($library, $field);
|
||||
|
||||
FormFieldLibrary::query()->whereKey($library->id)->increment('usage_count');
|
||||
|
||||
$this->schemaService->bumpVersion($schema);
|
||||
|
||||
@@ -33,6 +33,7 @@ final class FormSubmissionService
|
||||
public function __construct(
|
||||
private readonly FormLocaleResolver $localeResolver,
|
||||
private readonly FormValueService $valueService,
|
||||
private readonly FormFieldBindingService $bindingService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -199,7 +200,7 @@ final class FormSubmissionService
|
||||
*/
|
||||
private function buildSnapshot(FormSchema $schema): array
|
||||
{
|
||||
$schema->loadMissing(['fields', 'sections']);
|
||||
$schema->loadMissing(['fields.bindings', 'sections']);
|
||||
|
||||
return [
|
||||
'schema_version' => $schema->version,
|
||||
@@ -235,7 +236,7 @@ final class FormSubmissionService
|
||||
'is_required' => (bool) $f->is_required,
|
||||
'is_filterable' => (bool) $f->is_filterable,
|
||||
'is_pii' => (bool) $f->is_pii,
|
||||
'binding' => $f->binding,
|
||||
'binding' => $this->bindingService->toJsonShape($f->bindings->first()),
|
||||
'conditional_logic' => $f->conditional_logic,
|
||||
'translations' => $f->translations,
|
||||
'value_storage_hint' => $f->value_storage_hint instanceof \BackedEnum ? $f->value_storage_hint->value : $f->value_storage_hint,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Bindings;
|
||||
|
||||
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\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* External API shape parity: `FormFieldResource::binding` and
|
||||
* `FormFieldLibraryResource::default_binding` must match the pre-WS-5a
|
||||
* JSON exactly, but source from the relational table via the service.
|
||||
*/
|
||||
final class FormFieldResourceBindingOutputTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Organisation $org;
|
||||
|
||||
private User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(RoleSeeder::class);
|
||||
$this->org = Organisation::factory()->create();
|
||||
$this->admin = User::factory()->create();
|
||||
$this->org->users()->attach($this->admin, ['role' => 'org_admin']);
|
||||
}
|
||||
|
||||
public function test_form_field_resource_serialises_entity_owned_binding(): void
|
||||
{
|
||||
Sanctum::actingAs($this->admin);
|
||||
$schema = FormSchema::factory()->create(['organisation_id' => $this->org->id]);
|
||||
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
||||
FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create();
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->org->id}/forms/schemas/{$schema->id}/fields");
|
||||
$response->assertOk();
|
||||
$payload = collect($response->json('data'))->firstWhere('id', $field->id);
|
||||
$this->assertSame([
|
||||
'mode' => 'entity_owned',
|
||||
'entity' => 'person',
|
||||
'column' => 'email',
|
||||
], $payload['binding']);
|
||||
}
|
||||
|
||||
public function test_form_field_resource_returns_null_binding_when_none(): void
|
||||
{
|
||||
Sanctum::actingAs($this->admin);
|
||||
$schema = FormSchema::factory()->create(['organisation_id' => $this->org->id]);
|
||||
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->org->id}/forms/schemas/{$schema->id}/fields");
|
||||
$response->assertOk();
|
||||
$payload = collect($response->json('data'))->firstWhere('id', $field->id);
|
||||
$this->assertNull($payload['binding']);
|
||||
}
|
||||
|
||||
public function test_form_field_library_resource_serialises_default_binding(): void
|
||||
{
|
||||
Sanctum::actingAs($this->admin);
|
||||
$library = FormFieldLibrary::factory()->create(['organisation_id' => $this->org->id]);
|
||||
FormFieldBinding::factory()->forLibrary($library)->mirrored('user_profile', 'bio')->create();
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->org->id}/forms/field-library/{$library->id}");
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('data.default_binding', [
|
||||
'mode' => 'mirrored',
|
||||
'entity' => 'user_profile',
|
||||
'column' => 'bio',
|
||||
'sync_direction' => 'write_on_submit',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Bindings;
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldBindingMode;
|
||||
use App\Models\FormBuilder\FormFieldBinding;
|
||||
use App\Models\FormBuilder\FormFieldLibrary;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\Organisation;
|
||||
use App\Services\FormBuilder\FormFieldService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class InsertFromLibraryCopiesBindingsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_insert_from_library_copies_all_bindings_and_increments_usage(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
||||
$library = FormFieldLibrary::factory()->create([
|
||||
'organisation_id' => $org->id,
|
||||
'usage_count' => 0,
|
||||
]);
|
||||
|
||||
FormFieldBinding::factory()->forLibrary($library)->entityOwned('person', 'email')->create();
|
||||
FormFieldBinding::factory()->forLibrary($library)->mirrored('user_profile', 'bio')->create();
|
||||
|
||||
$service = $this->app->make(FormFieldService::class);
|
||||
$field = $service->insertFromLibrary($schema, $library);
|
||||
|
||||
$copied = FormFieldBinding::query()
|
||||
->withoutGlobalScopes()
|
||||
->where('owner_type', 'form_field')
|
||||
->where('owner_id', $field->id)
|
||||
->orderBy('target_attribute')
|
||||
->get();
|
||||
|
||||
$this->assertCount(2, $copied);
|
||||
$this->assertSame(['bio', 'email'], $copied->pluck('target_attribute')->all());
|
||||
$this->assertSame(FormFieldBindingMode::Mirrored, $copied->firstWhere('target_attribute', 'bio')->mode);
|
||||
$this->assertSame(FormFieldBindingMode::EntityOwned, $copied->firstWhere('target_attribute', 'email')->mode);
|
||||
|
||||
$this->assertSame(1, (int) $library->fresh()->usage_count);
|
||||
}
|
||||
|
||||
public function test_insert_from_library_without_bindings_creates_field_without_bindings(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
||||
$library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]);
|
||||
|
||||
$service = $this->app->make(FormFieldService::class);
|
||||
$field = $service->insertFromLibrary($schema, $library);
|
||||
|
||||
$this->assertSame(0, FormFieldBinding::query()
|
||||
->withoutGlobalScopes()
|
||||
->where('owner_type', 'form_field')
|
||||
->where('owner_id', $field->id)
|
||||
->count(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Bindings;
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldBindingMode;
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Enums\FormBuilder\FormPurpose;
|
||||
use App\Enums\FormBuilder\FormSchemaSnapshotMode;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormFieldBinding;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use App\Services\FormBuilder\FormSubmissionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Asserts the `schema_snapshot` JSON keeps its ARCH §4.6.1 / §6.3 shape
|
||||
* after WS-5a: the snapshot writer now serialises bindings from the
|
||||
* relational table via FormFieldBindingService::toJsonShape, but the
|
||||
* per-field JSON embedded in the snapshot is byte-for-byte the pre-WS-5a
|
||||
* shape for entity-owned and mirrored patterns.
|
||||
*/
|
||||
final class SchemaSnapshotEmbedsBindingFromRelationalTableTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_snapshot_embeds_entity_owned_binding_in_arch_shape(): void
|
||||
{
|
||||
$schema = $this->schemaWithSnapshot();
|
||||
|
||||
$field = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'field_type' => FormFieldType::EMAIL->value,
|
||||
'slug' => 'email',
|
||||
'label' => 'E-mail',
|
||||
]);
|
||||
FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create();
|
||||
|
||||
$submission = $this->submitFor($schema);
|
||||
$snapshot = $submission->fresh()->schema_snapshot;
|
||||
|
||||
$this->assertIsArray($snapshot);
|
||||
$embedded = collect($snapshot['fields'])->firstWhere('id', $field->id);
|
||||
$this->assertNotNull($embedded);
|
||||
$this->assertSame([
|
||||
'mode' => 'entity_owned',
|
||||
'entity' => 'person',
|
||||
'column' => 'email',
|
||||
], $embedded['binding']);
|
||||
}
|
||||
|
||||
public function test_snapshot_embeds_mirrored_binding_with_sync_direction(): void
|
||||
{
|
||||
$schema = $this->schemaWithSnapshot();
|
||||
|
||||
$field = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'field_type' => FormFieldType::TEXT->value,
|
||||
'slug' => 'noodcontact',
|
||||
'label' => 'Noodcontact',
|
||||
]);
|
||||
FormFieldBinding::factory()->forField($field)->mirrored('user_profile', 'emergency_contact_name')->create();
|
||||
|
||||
$submission = $this->submitFor($schema);
|
||||
$snapshot = $submission->fresh()->schema_snapshot;
|
||||
|
||||
$embedded = collect($snapshot['fields'])->firstWhere('id', $field->id);
|
||||
$this->assertSame([
|
||||
'mode' => 'mirrored',
|
||||
'entity' => 'user_profile',
|
||||
'column' => 'emergency_contact_name',
|
||||
'sync_direction' => 'write_on_submit',
|
||||
], $embedded['binding']);
|
||||
}
|
||||
|
||||
public function test_snapshot_embeds_null_binding_for_form_owned_field(): void
|
||||
{
|
||||
$schema = $this->schemaWithSnapshot();
|
||||
|
||||
$field = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'field_type' => FormFieldType::TEXT->value,
|
||||
'slug' => 'motivatie',
|
||||
'label' => 'Motivatie',
|
||||
]);
|
||||
|
||||
$submission = $this->submitFor($schema);
|
||||
$snapshot = $submission->fresh()->schema_snapshot;
|
||||
|
||||
$embedded = collect($snapshot['fields'])->firstWhere('id', $field->id);
|
||||
$this->assertNull($embedded['binding']);
|
||||
}
|
||||
|
||||
private function schemaWithSnapshot(): FormSchema
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
|
||||
return FormSchema::factory()->create([
|
||||
'organisation_id' => $org->id,
|
||||
'purpose' => FormPurpose::USER_PROFILE->value,
|
||||
'snapshot_mode' => FormSchemaSnapshotMode::ALWAYS->value,
|
||||
]);
|
||||
}
|
||||
|
||||
private function submitFor(FormSchema $schema): FormSubmission
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$service = $this->app->make(FormSubmissionService::class);
|
||||
|
||||
$submission = $service->createDraft($schema, $user, $user);
|
||||
|
||||
return $service->submit($submission, $user);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user