feat(form-builder): add PersonProvisioner with race-safe firstOrCreate (WS-6)
PersonProvisioner reads bindings from schema_snapshot (RFC Q6) and provisions Persons via lockForUpdate + firstOrCreate (RFC Q8). Person is event-scoped (Person::$organisationScopeColumn = 'event_id'), so the lookup matches by (email, event_id) — cross-event submissions never collide. Throws PersonProvisioningException on misconfiguration (failsafe — publish guards should prevent these at config time): no_transaction, no_event, no_identity_key, identity_key_missing_value, no_crowd_type. Snapshot enrichment: FormFieldBindingService::toApplicatorShape + FormSubmissionService snapshot now adds a 'bindings' (plural) key with binding id, merge_strategy, trust_level, is_identity_key. Singular 'binding' key kept for legacy webhook / GDPR readers. Includes RFC V4 state-injection concurrency test asserting recovery semantics under lockForUpdate windows. Refs: RFC-WS-6.md §3 (Q6, Q8), §4 (V4) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
271
api/tests/Unit/FormBuilder/Bindings/PersonProvisionerTest.php
Normal file
271
api/tests/Unit/FormBuilder/Bindings/PersonProvisionerTest.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\FormBuilder\Bindings;
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
|
||||
use App\Exceptions\FormBuilder\PersonProvisioningException;
|
||||
use App\FormBuilder\Bindings\PersonProvisioner;
|
||||
use App\Models\CrowdType;
|
||||
use App\Models\Event;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormFieldBinding;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\FormBuilder\FormValue;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Person;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class PersonProvisionerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_creates_person_when_none_exists_for_email(): void
|
||||
{
|
||||
$submission = $this->makeSubmissionWithEmail('jan@example.nl', firstName: 'Jan', lastName: 'Jansen');
|
||||
|
||||
DB::transaction(function () use ($submission): void {
|
||||
$person = $this->provisioner()->provisionFromSubmission($submission);
|
||||
$this->assertSame('jan@example.nl', $person->email);
|
||||
$this->assertSame('Jan', $person->first_name);
|
||||
$this->assertSame('Jansen', $person->last_name);
|
||||
$this->assertSame($submission->event_id, $person->event_id);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_returns_existing_person_on_repeat_submission(): void
|
||||
{
|
||||
$submission = $this->makeSubmissionWithEmail('jan@example.nl');
|
||||
|
||||
$first = DB::transaction(fn (): Person => $this->provisioner()->provisionFromSubmission($submission));
|
||||
|
||||
// Re-submit same email (different submission, same event)
|
||||
/** @var Event $event */
|
||||
$event = $submission->event;
|
||||
$submission2 = $this->makeSubmissionWithEmail('jan@example.nl', event: $event);
|
||||
|
||||
$second = DB::transaction(fn (): Person => $this->provisioner()->provisionFromSubmission($submission2));
|
||||
|
||||
$this->assertSame($first->id, $second->id);
|
||||
$this->assertSame(1, Person::query()->withoutGlobalScopes()->where('email', 'jan@example.nl')->count());
|
||||
}
|
||||
|
||||
public function test_no_transaction_guard_exists(): void
|
||||
{
|
||||
// The actual "no transaction" branch in provisionFromSubmission is
|
||||
// defensive runtime validation; RefreshDatabase wraps every PHPUnit
|
||||
// test in a transaction so we cannot exit one cleanly here. The
|
||||
// guard is exercised in production via the listener path
|
||||
// (ApplyBindingsOnFormSubmit calls DB::transaction explicitly).
|
||||
$reflection = new \ReflectionClass(\App\FormBuilder\Bindings\PersonProvisioner::class);
|
||||
$source = file_get_contents($reflection->getFileName());
|
||||
$this->assertStringContainsString('no_transaction', $source);
|
||||
$this->assertStringContainsString('DB::transactionLevel()', $source);
|
||||
}
|
||||
|
||||
public function test_throws_when_no_identity_key_binding(): void
|
||||
{
|
||||
$submission = $this->makeSubmissionWithEmail('jan@example.nl', identityKey: false);
|
||||
|
||||
DB::transaction(function () use ($submission): void {
|
||||
try {
|
||||
$this->provisioner()->provisionFromSubmission($submission);
|
||||
$this->fail('Expected PersonProvisioningException');
|
||||
} catch (PersonProvisioningException $e) {
|
||||
$this->assertSame('no_identity_key', $e->reasonCode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function test_multi_tenant_isolation_same_email_different_event(): void
|
||||
{
|
||||
$sub1 = $this->makeSubmissionWithEmail('jan@example.nl');
|
||||
$sub2 = $this->makeSubmissionWithEmail('jan@example.nl'); // new event
|
||||
|
||||
$p1 = DB::transaction(fn (): Person => $this->provisioner()->provisionFromSubmission($sub1));
|
||||
$p2 = DB::transaction(fn (): Person => $this->provisioner()->provisionFromSubmission($sub2));
|
||||
|
||||
$this->assertNotSame($p1->id, $p2->id);
|
||||
$this->assertSame(2, Person::query()->withoutGlobalScopes()->where('email', 'jan@example.nl')->count());
|
||||
}
|
||||
|
||||
public function test_snapshot_is_truth_ignores_post_submit_binding_edits(): void
|
||||
{
|
||||
$submission = $this->makeSubmissionWithEmail('jan@example.nl');
|
||||
|
||||
// Edit the live binding AFTER the snapshot was taken — flip
|
||||
// is_identity_key off. PersonProvisioner should still find the
|
||||
// identity key from the snapshot and provision normally.
|
||||
FormFieldBinding::query()
|
||||
->withoutGlobalScopes()
|
||||
->where('target_attribute', 'email')
|
||||
->update(['is_identity_key' => false]);
|
||||
|
||||
$person = DB::transaction(fn (): Person => $this->provisioner()->provisionFromSubmission($submission));
|
||||
$this->assertSame('jan@example.nl', $person->email);
|
||||
}
|
||||
|
||||
public function test_throws_when_identity_key_value_is_null(): void
|
||||
{
|
||||
$submission = $this->makeSubmissionWithEmail(null);
|
||||
|
||||
DB::transaction(function () use ($submission): void {
|
||||
try {
|
||||
$this->provisioner()->provisionFromSubmission($submission);
|
||||
$this->fail('Expected PersonProvisioningException');
|
||||
} catch (PersonProvisioningException $e) {
|
||||
$this->assertSame('identity_key_missing_value', $e->reasonCode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function test_throws_when_identity_key_form_value_absent(): void
|
||||
{
|
||||
// Schema has the binding, but no form_value row was written
|
||||
$submission = $this->makeSubmissionWithEmail('jan@example.nl', writeFormValue: false);
|
||||
|
||||
DB::transaction(function () use ($submission): void {
|
||||
try {
|
||||
$this->provisioner()->provisionFromSubmission($submission);
|
||||
$this->fail('Expected PersonProvisioningException');
|
||||
} catch (PersonProvisioningException $e) {
|
||||
$this->assertSame('identity_key_missing_value', $e->reasonCode);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function provisioner(): PersonProvisioner
|
||||
{
|
||||
return $this->app->make(PersonProvisioner::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fully-snapshot'd event_registration submission. The schema
|
||||
* has at minimum an identity-key binding to person.email plus
|
||||
* first_name + last_name bindings; form_values are written for each.
|
||||
*/
|
||||
private function makeSubmissionWithEmail(
|
||||
?string $email,
|
||||
?Event $event = null,
|
||||
bool $identityKey = true,
|
||||
bool $writeFormValue = true,
|
||||
string $firstName = 'Jan',
|
||||
string $lastName = 'Jansen',
|
||||
): FormSubmission {
|
||||
$event ??= Event::factory()->create();
|
||||
/** @var Organisation $organisation */
|
||||
$organisation = Organisation::query()->find($event->organisation_id) ?? Organisation::factory()->create();
|
||||
|
||||
// PersonProvisioner needs an active CrowdType in the org to set
|
||||
// crowd_type_id on a freshly-provisioned Person (NOT NULL column).
|
||||
if (! CrowdType::query()->where('organisation_id', $organisation->id)->where('is_active', true)->exists()) {
|
||||
CrowdType::factory()->create([
|
||||
'organisation_id' => $organisation->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $organisation->id,
|
||||
]);
|
||||
|
||||
$emailField = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'slug' => 'email',
|
||||
]);
|
||||
FormFieldBinding::factory()->forField($emailField)->entityOwned('person', 'email')->create([
|
||||
'is_identity_key' => $identityKey,
|
||||
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
|
||||
'trust_level' => 80,
|
||||
]);
|
||||
|
||||
$firstNameField = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'slug' => 'first_name',
|
||||
]);
|
||||
FormFieldBinding::factory()->forField($firstNameField)->entityOwned('person', 'first_name')->create([
|
||||
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
|
||||
'trust_level' => 70,
|
||||
]);
|
||||
|
||||
$lastNameField = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'slug' => 'last_name',
|
||||
]);
|
||||
FormFieldBinding::factory()->forField($lastNameField)->entityOwned('person', 'last_name')->create([
|
||||
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
|
||||
'trust_level' => 60,
|
||||
]);
|
||||
|
||||
$submission = FormSubmission::factory()
|
||||
->forEvent($event)
|
||||
->create(['form_schema_id' => $schema->id]);
|
||||
|
||||
// Build snapshot manually — identical shape to FormSubmissionService::buildSnapshot.
|
||||
$submission->schema_snapshot = [
|
||||
'fields' => [
|
||||
$this->snapshotField($emailField, 'person', 'email', identityKey: $identityKey, trustLevel: 80),
|
||||
$this->snapshotField($firstNameField, 'person', 'first_name', trustLevel: 70),
|
||||
$this->snapshotField($lastNameField, 'person', 'last_name', trustLevel: 60),
|
||||
],
|
||||
];
|
||||
$submission->save();
|
||||
|
||||
if ($writeFormValue) {
|
||||
$this->writeValue($submission->id, $emailField->id, $email);
|
||||
$this->writeValue($submission->id, $firstNameField->id, $firstName);
|
||||
$this->writeValue($submission->id, $lastNameField->id, $lastName);
|
||||
}
|
||||
|
||||
return $submission->fresh();
|
||||
}
|
||||
|
||||
private function writeValue(string $submissionId, string $fieldId, mixed $value): void
|
||||
{
|
||||
$row = new FormValue();
|
||||
$row->form_submission_id = $submissionId;
|
||||
$row->form_field_id = $fieldId;
|
||||
// NOT NULL column; empty-string for the explicit-null test scenario.
|
||||
// The cast is array; assignment via Eloquent's __set passes through.
|
||||
$row->setAttribute('value', $value ?? '');
|
||||
$row->value_anonymised = false;
|
||||
$row->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function snapshotField(
|
||||
FormField $field,
|
||||
string $entity,
|
||||
string $column,
|
||||
bool $identityKey = false,
|
||||
int $trustLevel = 50,
|
||||
): array {
|
||||
/** @var FormFieldBinding $binding */
|
||||
$binding = $field->bindings()->first();
|
||||
$bindingId = (string) $binding->id;
|
||||
|
||||
return [
|
||||
'id' => (string) $field->id,
|
||||
'slug' => (string) $field->slug,
|
||||
'field_type' => $field->field_type,
|
||||
'sort_order' => (int) $field->sort_order,
|
||||
'bindings' => [
|
||||
[
|
||||
'id' => $bindingId,
|
||||
'mode' => 'entity_owned',
|
||||
'entity' => $entity,
|
||||
'column' => $column,
|
||||
'merge_strategy' => 'overwrite',
|
||||
'trust_level' => $trustLevel,
|
||||
'is_identity_key' => $identityKey,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user