Files
crewli/api/tests/Unit/FormBuilder/Bindings/BindingConflictResolverTest.php
bert.hausmans 47265e9d4f feat(form-builder): add BindingConflictResolver per RFC Q7 (WS-6)
Resolves bindings within a submission to one winner per (target_entity,
target_attribute) group. Candidate set = form_values rows present
(absence excludes; null value is explicit clear and IS a candidate).
Trust-precedence with sort_order tie-break. Section-filtering for
RFC Q10 stub future-readiness.

Pure-logic resolver — no DB writes, only reads form_values for the
candidate gate. Works against the 'bindings' (plural) snapshot key
introduced alongside PersonProvisioner.

Refs: RFC-WS-6.md §3 (Q7, Q10)

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

216 lines
8.1 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Unit\FormBuilder\Bindings;
use App\Enums\FormBuilder\BindingTargetType;
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
use App\FormBuilder\Bindings\BindingConflictResolver;
use App\FormBuilder\Bindings\ResolvedBinding;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormValue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class BindingConflictResolverTest extends TestCase
{
use RefreshDatabase;
public function test_single_binding_wins_trivially(): void
{
$submission = $this->makeSubmission([
$this->fieldRow('email', 'person', 'email', trustLevel: 80, value: 'a@example.nl'),
]);
$resolved = $this->resolver()->resolve($submission);
$this->assertCount(1, $resolved);
$this->assertSame('a@example.nl', $resolved[0]->value);
$this->assertSame('person', $resolved[0]->targetEntity);
$this->assertSame(BindingTargetType::SCALAR, $resolved[0]->targetType);
}
public function test_higher_trust_wins(): void
{
$submission = $this->makeSubmission([
$this->fieldRow('email_low', 'person', 'email', trustLevel: 30, value: 'low@example.nl'),
$this->fieldRow('email_high', 'person', 'email', trustLevel: 90, value: 'high@example.nl'),
]);
$resolved = $this->resolver()->resolve($submission);
$this->assertCount(1, $resolved);
$this->assertSame('high@example.nl', $resolved[0]->value);
}
public function test_equal_trust_breaks_by_sort_order(): void
{
$submission = $this->makeSubmission([
$this->fieldRow('first_email', 'person', 'email', trustLevel: 50, value: 'first@example.nl', sortOrder: 1),
$this->fieldRow('second_email', 'person', 'email', trustLevel: 50, value: 'second@example.nl', sortOrder: 0),
]);
$resolved = $this->resolver()->resolve($submission);
$this->assertCount(1, $resolved);
// sort_order 0 wins over sort_order 1 at equal trust
$this->assertSame('second@example.nl', $resolved[0]->value);
}
public function test_field_absent_from_form_values_is_not_a_candidate(): void
{
$submission = $this->makeSubmission([
$this->fieldRow('email_skipped', 'person', 'email', trustLevel: 90, writeValue: false),
$this->fieldRow('first_name', 'person', 'first_name', trustLevel: 50, value: 'Jan'),
]);
$resolved = $this->resolver()->resolve($submission);
$this->assertCount(1, $resolved);
$this->assertSame('person.first_name', $resolved[0]->targetEntity . '.' . $resolved[0]->targetAttribute);
}
public function test_explicit_null_value_is_a_candidate(): void
{
$submission = $this->makeSubmission([
$this->fieldRow('email', 'person', 'email', trustLevel: 80, value: ''),
]);
$resolved = $this->resolver()->resolve($submission);
$this->assertCount(1, $resolved);
$this->assertTrue($resolved[0]->valueIsExplicit);
}
public function test_section_filter_includes_only_matching_section(): void
{
$submission = $this->makeSubmission([
$this->fieldRow('a_email', 'person', 'email', trustLevel: 80, value: 'a@example.nl', sectionSlug: 'section-a'),
$this->fieldRow('b_first_name', 'person', 'first_name', trustLevel: 80, value: 'Jan', sectionSlug: 'section-b'),
]);
$submission->schema_snapshot = array_merge($submission->schema_snapshot, [
'sections' => [
['id' => 'sec-a-id', 'slug' => 'section-a'],
['id' => 'sec-b-id', 'slug' => 'section-b'],
],
]);
$submission->save();
$resolved = $this->resolver()->resolve($submission, sectionId: 'sec-a-id');
$this->assertCount(1, $resolved);
$this->assertSame('person.email', $resolved[0]->targetEntity . '.' . $resolved[0]->targetAttribute);
}
public function test_null_section_id_returns_all_candidates(): void
{
$submission = $this->makeSubmission([
$this->fieldRow('a_email', 'person', 'email', trustLevel: 80, value: 'a@example.nl', sectionSlug: 'section-a'),
$this->fieldRow('b_first_name', 'person', 'first_name', trustLevel: 80, value: 'Jan', sectionSlug: 'section-b'),
]);
$resolved = $this->resolver()->resolve($submission);
$this->assertCount(2, $resolved);
}
public function test_different_target_groups_resolved_independently(): void
{
$submission = $this->makeSubmission([
$this->fieldRow('email', 'person', 'email', trustLevel: 80, value: 'a@example.nl'),
$this->fieldRow('first_name', 'person', 'first_name', trustLevel: 70, value: 'Jan'),
$this->fieldRow('last_name', 'person', 'last_name', trustLevel: 60, value: 'Jansen'),
]);
$resolved = $this->resolver()->resolve($submission);
$this->assertCount(3, $resolved);
$attributes = array_map(fn (ResolvedBinding $r): string => $r->targetAttribute, $resolved);
$this->assertContains('email', $attributes);
$this->assertContains('first_name', $attributes);
$this->assertContains('last_name', $attributes);
}
public function test_empty_form_values_returns_empty(): void
{
$submission = $this->makeSubmission([]);
$this->assertSame([], $this->resolver()->resolve($submission));
}
private function resolver(): BindingConflictResolver
{
return $this->app->make(BindingConflictResolver::class);
}
/**
* @param list<array<string, mixed>> $fieldRows
*/
private function makeSubmission(array $fieldRows): FormSubmission
{
$schema = FormSchema::factory()->create();
$submission = FormSubmission::factory()->create([
'form_schema_id' => $schema->id,
'organisation_id' => $schema->organisation_id,
]);
$snapshotFields = [];
foreach ($fieldRows as $row) {
$field = FormField::factory()->create([
'form_schema_id' => $schema->id,
'slug' => $row['slug'],
'sort_order' => $row['sort_order'],
]);
$snapshotFields[] = [
'id' => (string) $field->id,
'slug' => (string) $row['slug'],
'sort_order' => (int) $row['sort_order'],
'section_slug' => $row['section_slug'] ?? null,
'bindings' => [[
'id' => 'bnd-' . $row['slug'],
'mode' => 'entity_owned',
'entity' => $row['entity'],
'column' => $row['column'],
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
'trust_level' => $row['trust_level'],
'is_identity_key' => false,
]],
];
if ($row['write_value'] ?? true) {
$val = new FormValue();
$val->form_submission_id = $submission->id;
$val->form_field_id = $field->id;
$val->setAttribute('value', $row['value']);
$val->value_anonymised = false;
$val->save();
}
}
$submission->schema_snapshot = ['fields' => $snapshotFields];
$submission->save();
return $submission->fresh();
}
/**
* @return array<string, mixed>
*/
private function fieldRow(
string $slug,
string $entity,
string $column,
int $trustLevel,
mixed $value = null,
int $sortOrder = 0,
bool $writeValue = true,
?string $sectionSlug = null,
): array {
return [
'slug' => $slug,
'entity' => $entity,
'column' => $column,
'trust_level' => $trustLevel,
'value' => $value ?? '',
'sort_order' => $sortOrder,
'write_value' => $writeValue,
'section_slug' => $sectionSlug,
];
}
}