Files
crewli/api/tests/Feature/FormBuilder/Bindings/PersonProvisionerConcurrencyTest.php
bert.hausmans d257d64925 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>
2026-04-26 12:43:12 +02:00

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();
}
}