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:
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\FormBuilder;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-WS-6 §3 (Q8) — failure during PersonProvisioner operation.
|
||||||
|
*
|
||||||
|
* Reason codes (informal contract):
|
||||||
|
* - 'no_transaction' called outside DB::transaction
|
||||||
|
* - 'no_identity_key' schema has no is_identity_key=true binding for person
|
||||||
|
* - 'no_event' submission missing event_id (Person scope is event_id)
|
||||||
|
* - 'identity_key_missing_value' identity-key form_value is absent or null
|
||||||
|
*/
|
||||||
|
final class PersonProvisioningException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $reasonCode,
|
||||||
|
public readonly string $submissionId,
|
||||||
|
?string $message = null,
|
||||||
|
) {
|
||||||
|
parent::__construct($message ?? "Person provisioning failed: {$reasonCode} (submission {$submissionId})");
|
||||||
|
}
|
||||||
|
}
|
||||||
233
api/app/FormBuilder/Bindings/PersonProvisioner.php
Normal file
233
api/app/FormBuilder/Bindings/PersonProvisioner.php
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\FormBuilder\Bindings;
|
||||||
|
|
||||||
|
use App\Exceptions\FormBuilder\PersonProvisioningException;
|
||||||
|
use App\Models\CrowdType;
|
||||||
|
use App\Models\FormBuilder\FormSubmission;
|
||||||
|
use App\Models\FormBuilder\FormValue;
|
||||||
|
use App\Models\Person;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-WS-6 §3 (Q8) — provisions a Person for an event_registration
|
||||||
|
* submission. Reads bindings from `schema_snapshot.fields[*].bindings`
|
||||||
|
* (RFC Q6 — snapshot is truth). Race-safe via lockForUpdate +
|
||||||
|
* firstOrCreate semantics: callers MUST be inside a DB::transaction.
|
||||||
|
*
|
||||||
|
* Multi-tenancy note: Person is scoped by `event_id` (see
|
||||||
|
* Person::$organisationScopeColumn). The provisioner lookups by
|
||||||
|
* `(email, event_id)`; cross-event submissions never collide.
|
||||||
|
*/
|
||||||
|
final readonly class PersonProvisioner
|
||||||
|
{
|
||||||
|
public function __construct(private BindingTypeRegistry $registry) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws PersonProvisioningException
|
||||||
|
*/
|
||||||
|
public function provisionFromSubmission(FormSubmission $submission): Person
|
||||||
|
{
|
||||||
|
if (DB::transactionLevel() < 1) {
|
||||||
|
throw new PersonProvisioningException(
|
||||||
|
'no_transaction',
|
||||||
|
(string) $submission->id,
|
||||||
|
'PersonProvisioner must be invoked inside DB::transaction',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($submission->event_id === null) {
|
||||||
|
throw new PersonProvisioningException(
|
||||||
|
'no_event',
|
||||||
|
(string) $submission->id,
|
||||||
|
'event_registration submission has no event_id',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bindings = $this->extractPersonBindings($submission);
|
||||||
|
$identityBinding = $this->findIdentityKeyBinding($bindings, $submission);
|
||||||
|
$emailValue = $this->readFormValue($submission, $identityBinding['form_field_id']);
|
||||||
|
|
||||||
|
if (! is_string($emailValue) || $emailValue === '') {
|
||||||
|
throw new PersonProvisioningException(
|
||||||
|
'identity_key_missing_value',
|
||||||
|
(string) $submission->id,
|
||||||
|
"identity-key field {$identityBinding['form_field_id']} has no usable value",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = Person::query()
|
||||||
|
->withoutGlobalScopes()
|
||||||
|
->where('email', $emailValue)
|
||||||
|
->where('event_id', $submission->event_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing !== null) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attributes = $this->buildCreateAttributes($submission, $bindings, $identityBinding);
|
||||||
|
$attributes['email'] = $emailValue;
|
||||||
|
$attributes['event_id'] = $submission->event_id;
|
||||||
|
$attributes['crowd_type_id'] = $this->resolveCrowdTypeId($submission);
|
||||||
|
|
||||||
|
// firstOrCreate semantics: an identical row created concurrently
|
||||||
|
// (between our lockForUpdate window and the insert) surfaces via
|
||||||
|
// the unique-constraint and is reread.
|
||||||
|
return Person::query()
|
||||||
|
->withoutGlobalScopes()
|
||||||
|
->firstOrCreate(
|
||||||
|
[
|
||||||
|
'email' => $emailValue,
|
||||||
|
'event_id' => $submission->event_id,
|
||||||
|
],
|
||||||
|
$attributes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{form_field_id:string, binding:array<string,mixed>}>
|
||||||
|
*/
|
||||||
|
private function extractPersonBindings(FormSubmission $submission): array
|
||||||
|
{
|
||||||
|
$snapshot = $submission->schema_snapshot;
|
||||||
|
if (! is_array($snapshot)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = $snapshot['fields'] ?? [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if (! is_array($field)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$fieldId = (string) ($field['id'] ?? '');
|
||||||
|
if ($fieldId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$bindings = $field['bindings'] ?? [];
|
||||||
|
if (! is_array($bindings)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($bindings as $binding) {
|
||||||
|
if (! is_array($binding)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (($binding['entity'] ?? null) !== 'person') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[] = [
|
||||||
|
'form_field_id' => $fieldId,
|
||||||
|
'binding' => $binding,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{form_field_id:string, binding:array<string,mixed>}> $bindings
|
||||||
|
* @return array{form_field_id:string, binding:array<string,mixed>}
|
||||||
|
*
|
||||||
|
* @throws PersonProvisioningException
|
||||||
|
*/
|
||||||
|
private function findIdentityKeyBinding(array $bindings, FormSubmission $submission): array
|
||||||
|
{
|
||||||
|
foreach ($bindings as $entry) {
|
||||||
|
if (($entry['binding']['is_identity_key'] ?? false) === true) {
|
||||||
|
return $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new PersonProvisioningException(
|
||||||
|
'no_identity_key',
|
||||||
|
(string) $submission->id,
|
||||||
|
'no person.* binding flagged is_identity_key=true on this schema',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a default crowd_type_id for a freshly-provisioned Person.
|
||||||
|
* Person.crowd_type_id is NOT NULL on the migration. Session 2 picks
|
||||||
|
* the first active CrowdType for the submission's organisation. A
|
||||||
|
* future per-schema setting (`default_crowd_type_id`) is the proper
|
||||||
|
* resolution but out-of-scope here.
|
||||||
|
*
|
||||||
|
* @throws PersonProvisioningException
|
||||||
|
*/
|
||||||
|
private function resolveCrowdTypeId(FormSubmission $submission): string
|
||||||
|
{
|
||||||
|
$orgId = (string) $submission->organisation_id;
|
||||||
|
$crowdType = CrowdType::query()
|
||||||
|
->withoutGlobalScopes()
|
||||||
|
->where('organisation_id', $orgId)
|
||||||
|
->where('is_active', true)->oldest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($crowdType === null) {
|
||||||
|
throw new PersonProvisioningException(
|
||||||
|
'no_crowd_type',
|
||||||
|
(string) $submission->id,
|
||||||
|
"no active CrowdType available for organisation {$orgId}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $crowdType->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readFormValue(FormSubmission $submission, string $formFieldId): mixed
|
||||||
|
{
|
||||||
|
// Use Eloquent so the JSON cast on `value` is applied. The
|
||||||
|
// query builder's value() returns the raw column (JSON-encoded
|
||||||
|
// string), which would round-trip incorrectly for scalars.
|
||||||
|
$row = FormValue::query()
|
||||||
|
->withoutGlobalScopes()
|
||||||
|
->where('form_submission_id', $submission->id)
|
||||||
|
->where('form_field_id', $formFieldId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $row?->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{form_field_id:string, binding:array<string,mixed>}> $bindings
|
||||||
|
* @param array{form_field_id:string, binding:array<string,mixed>} $identityBinding
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function buildCreateAttributes(
|
||||||
|
FormSubmission $submission,
|
||||||
|
array $bindings,
|
||||||
|
array $identityBinding,
|
||||||
|
): array {
|
||||||
|
$personFillable = (new Person())->getFillable();
|
||||||
|
$attributes = [];
|
||||||
|
|
||||||
|
foreach ($bindings as $entry) {
|
||||||
|
if ($entry['form_field_id'] === $identityBinding['form_field_id']) {
|
||||||
|
// identity-key value is set explicitly on the create call
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$column = (string) ($entry['binding']['column'] ?? '');
|
||||||
|
if ($column === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! in_array($column, $personFillable, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! $this->registry->isKnown('person', $column)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = $this->readFormValue($submission, $entry['form_field_id']);
|
||||||
|
if ($value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$attributes[$column] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attributes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\FormBuilder\Bindings\BindingTypeRegistry;
|
use App\FormBuilder\Bindings\BindingTypeRegistry;
|
||||||
|
use App\FormBuilder\Bindings\PersonProvisioner;
|
||||||
use App\FormBuilder\Purposes\PurposeRegistry;
|
use App\FormBuilder\Purposes\PurposeRegistry;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\CrowdList;
|
use App\Models\CrowdList;
|
||||||
@@ -90,6 +91,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
$this->app->singleton(PurposeRegistry::class);
|
$this->app->singleton(PurposeRegistry::class);
|
||||||
$this->app->singleton(BindingTypeRegistry::class);
|
$this->app->singleton(BindingTypeRegistry::class);
|
||||||
|
$this->app->singleton(PersonProvisioner::class);
|
||||||
|
|
||||||
// Telescope is a dev-only debugging dashboard. Three-layer
|
// Telescope is a dev-only debugging dashboard. Three-layer
|
||||||
// defense keeps it out of production: composer `dont-discover`
|
// defense keeps it out of production: composer `dont-discover`
|
||||||
|
|||||||
@@ -150,6 +150,70 @@ final class FormFieldBindingService
|
|||||||
return $shape;
|
return $shape;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Richer snapshot shape for the WS-6 binding pipeline. Captures
|
||||||
|
* applicator-relevant metadata (binding id, merge_strategy,
|
||||||
|
* trust_level, is_identity_key) on top of the legacy
|
||||||
|
* mode/entity/column triple.
|
||||||
|
*
|
||||||
|
* RFC-WS-6.md §3 (Q6): the applicator reads from snapshot, not from
|
||||||
|
* the live form_field_bindings table — this shape carries everything
|
||||||
|
* needed for conflict resolution and person provisioning.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* id:string,
|
||||||
|
* mode:string,
|
||||||
|
* entity:string,
|
||||||
|
* column:string,
|
||||||
|
* sync_direction?:string,
|
||||||
|
* merge_strategy:string,
|
||||||
|
* trust_level:int,
|
||||||
|
* is_identity_key:bool,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toApplicatorShape(FormFieldBinding $binding): array
|
||||||
|
{
|
||||||
|
// FormFieldBinding casts mode/merge_strategy to enum already; access
|
||||||
|
// the value directly without redundant instanceof guards.
|
||||||
|
$shape = [
|
||||||
|
'id' => (string) $binding->id,
|
||||||
|
'mode' => $binding->mode->value,
|
||||||
|
'entity' => (string) $binding->target_entity,
|
||||||
|
'column' => (string) $binding->target_attribute,
|
||||||
|
'merge_strategy' => ($binding->merge_strategy ?? FormFieldBindingMergeStrategy::Overwrite)->value,
|
||||||
|
'trust_level' => (int) $binding->trust_level,
|
||||||
|
'is_identity_key' => (bool) $binding->is_identity_key,
|
||||||
|
];
|
||||||
|
if ($binding->sync_direction !== null && $binding->sync_direction !== '') {
|
||||||
|
$shape['sync_direction'] = (string) $binding->sync_direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build snapshot fragments for both the legacy `binding` (singular)
|
||||||
|
* key and the new WS-6 `bindings` (plural) key in one pass over the
|
||||||
|
* collection. Single helper so the FormSubmissionService snapshot
|
||||||
|
* loop accesses `$field->bindings` only once.
|
||||||
|
*
|
||||||
|
* @param iterable<FormFieldBinding> $bindings
|
||||||
|
* @return array{binding: array<string, mixed>|null, bindings: list<array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
public function snapshotShapesFor(iterable $bindings): array
|
||||||
|
{
|
||||||
|
$first = null;
|
||||||
|
$all = [];
|
||||||
|
foreach ($bindings as $binding) {
|
||||||
|
if ($first === null) {
|
||||||
|
$first = $this->toJsonShape($binding);
|
||||||
|
}
|
||||||
|
$all[] = $this->toApplicatorShape($binding);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['binding' => $first, 'bindings' => $all];
|
||||||
|
}
|
||||||
|
|
||||||
private function ownerTypeFor(FormField|FormFieldLibrary $owner): string
|
private function ownerTypeFor(FormField|FormFieldLibrary $owner): string
|
||||||
{
|
{
|
||||||
return $owner instanceof FormField ? 'form_field' : 'form_field_library';
|
return $owner instanceof FormField ? 'form_field' : 'form_field_library';
|
||||||
|
|||||||
@@ -243,7 +243,13 @@ final class FormSubmissionService
|
|||||||
'is_required' => (bool) $f->is_required,
|
'is_required' => (bool) $f->is_required,
|
||||||
'is_filterable' => (bool) $f->is_filterable,
|
'is_filterable' => (bool) $f->is_filterable,
|
||||||
'is_pii' => (bool) $f->is_pii,
|
'is_pii' => (bool) $f->is_pii,
|
||||||
'binding' => $this->bindingService->toJsonShape($f->bindings->first()),
|
// WS-6 RFC Q6 — singular 'binding' kept for legacy webhook /
|
||||||
|
// GDPR readers; plural 'bindings' carries every binding on
|
||||||
|
// the field with id, merge_strategy, trust_level,
|
||||||
|
// is_identity_key for PersonProvisioner / BindingConflictResolver
|
||||||
|
// / FormBindingApplicator. Single helper to avoid duplicated
|
||||||
|
// dynamic-property access inside this lambda.
|
||||||
|
...$this->bindingService->snapshotShapesFor($f->bindings),
|
||||||
'conditional_logic' => $this->conditionalLogicService->toJsonShape($f->rootConditionalLogicGroup()),
|
'conditional_logic' => $this->conditionalLogicService->toJsonShape($f->rootConditionalLogicGroup()),
|
||||||
'translations' => $this->stripOptionsFromTranslations($f->translations),
|
'translations' => $this->stripOptionsFromTranslations($f->translations),
|
||||||
'value_storage_hint' => $f->value_storage_hint instanceof \BackedEnum ? $f->value_storage_hint->value : $f->value_storage_hint,
|
'value_storage_hint' => $f->value_storage_hint instanceof \BackedEnum ? $f->value_storage_hint->value : $f->value_storage_hint,
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\FormBuilder\Bindings;
|
||||||
|
|
||||||
|
// RFC V4 — concurrent recovery via firstOrCreate semantics under
|
||||||
|
// lockForUpdate windows. State-injection PHPUnit assertion (per
|
||||||
|
// RFC §4 V4) — wall-clock concurrent load testing is a separate
|
||||||
|
// workstream tracked in BACKLOG: LOAD-TEST-FOUNDATION.
|
||||||
|
|
||||||
|
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 Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class PersonProvisionerConcurrencyTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_concurrent_provisioning_resolves_to_single_person_via_first_or_create(): void
|
||||||
|
{
|
||||||
|
$event = Event::factory()->create();
|
||||||
|
$organisation = Organisation::query()->find($event->organisation_id) ?? Organisation::factory()->create();
|
||||||
|
$crowdType = CrowdType::factory()->create([
|
||||||
|
'organisation_id' => $organisation->id,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$submission = $this->makeSubmission($event, 'race@test.nl');
|
||||||
|
|
||||||
|
DB::transaction(function () use ($submission, $crowdType): void {
|
||||||
|
// Transaction A inside lockForUpdate window — finds no Person yet
|
||||||
|
$existing = Person::query()
|
||||||
|
->withoutGlobalScopes()
|
||||||
|
->where('email', 'race@test.nl')
|
||||||
|
->where('event_id', $submission->event_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
$this->assertNull($existing);
|
||||||
|
|
||||||
|
// Inject: simulate transaction B has already inserted the Person
|
||||||
|
// (in real production, lockForUpdate would block this; we
|
||||||
|
// simulate the post-block state to assert recovery semantics)
|
||||||
|
DB::table('persons')->insert([
|
||||||
|
'id' => (string) Str::ulid(),
|
||||||
|
'event_id' => $submission->event_id,
|
||||||
|
'crowd_type_id' => $crowdType->id,
|
||||||
|
'email' => 'race@test.nl',
|
||||||
|
'first_name' => 'Pre',
|
||||||
|
'last_name' => 'Existing',
|
||||||
|
'is_blacklisted' => false,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Transaction A's firstOrCreate must recover and return the
|
||||||
|
// existing row rather than creating a duplicate.
|
||||||
|
$person = $this->app->make(PersonProvisioner::class)
|
||||||
|
->provisionFromSubmission($submission);
|
||||||
|
|
||||||
|
$this->assertSame('race@test.nl', $person->email);
|
||||||
|
$this->assertSame(
|
||||||
|
1,
|
||||||
|
Person::query()
|
||||||
|
->withoutGlobalScopes()
|
||||||
|
->where('email', 'race@test.nl')
|
||||||
|
->where('event_id', $submission->event_id)
|
||||||
|
->count(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeSubmission(Event $event, string $email): FormSubmission
|
||||||
|
{
|
||||||
|
$schema = FormSchema::factory()->create(['organisation_id' => $event->organisation_id]);
|
||||||
|
$emailField = FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'email']);
|
||||||
|
$binding = FormFieldBinding::factory()->forField($emailField)->entityOwned('person', 'email')
|
||||||
|
->create(['is_identity_key' => true, 'merge_strategy' => 'overwrite', 'trust_level' => 80]);
|
||||||
|
|
||||||
|
$submission = FormSubmission::factory()->forEvent($event)->create(['form_schema_id' => $schema->id]);
|
||||||
|
$submission->schema_snapshot = [
|
||||||
|
'fields' => [[
|
||||||
|
'id' => (string) $emailField->id,
|
||||||
|
'slug' => 'email',
|
||||||
|
'sort_order' => 0,
|
||||||
|
'bindings' => [[
|
||||||
|
'id' => (string) $binding->id,
|
||||||
|
'mode' => 'entity_owned',
|
||||||
|
'entity' => 'person',
|
||||||
|
'column' => 'email',
|
||||||
|
'merge_strategy' => 'overwrite',
|
||||||
|
'trust_level' => 80,
|
||||||
|
'is_identity_key' => true,
|
||||||
|
]],
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
$submission->save();
|
||||||
|
|
||||||
|
$row = new FormValue();
|
||||||
|
$row->form_submission_id = $submission->id;
|
||||||
|
$row->form_field_id = $emailField->id;
|
||||||
|
$row->setAttribute('value', $email);
|
||||||
|
$row->value_anonymised = false;
|
||||||
|
$row->save();
|
||||||
|
|
||||||
|
return $submission->fresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
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