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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user