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>
120 lines
4.6 KiB
PHP
120 lines
4.6 KiB
PHP
<?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();
|
|
}
|
|
}
|