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_schema_has_no_default_crowd_type(): void { $submission = $this->makeSubmissionWithEmail('jan@example.nl'); // Clear the field that the helper set up to satisfy the new contract. /** @var FormSchema $schema */ $schema = $submission->schema; $schema->default_crowd_type_id = null; $schema->save(); $submission = $submission->fresh(['schema']); DB::transaction(function () use ($submission): void { try { $this->provisioner()->provisionFromSubmission($submission); $this->fail('Expected PersonProvisioningException'); } catch (PersonProvisioningException $e) { $this->assertSame('no_default_crowd_type', $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). $crowdType = CrowdType::query() ->where('organisation_id', $organisation->id) ->where('is_active', true) ->first(); if ($crowdType === null) { $crowdType = CrowdType::factory()->create([ 'organisation_id' => $organisation->id, 'is_active' => true, ]); } $schema = FormSchema::factory()->create([ 'organisation_id' => $organisation->id, 'default_crowd_type_id' => $crowdType->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 */ 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, ], ], ]; } }