Session 2's PersonProvisioner picked CrowdType::oldest() for the org — silently wrong for multi-crowd_type orgs (Volunteer + Crew + Press are three distinct crowd_types in one org). Schemas now declare their target crowd_type explicitly via form_schemas.default_crowd_type_id. RequiresDefaultCrowdType publish guard prevents misconfigured event_registration schemas from publishing. PersonProvisioner: oldest() fallback removed entirely. Misconfiguration throws no_default_crowd_type at runtime; publish guard prevents it at config time. Migration uses a plain ulid() column without DB-level FK because SQLite's table-rebuild on ALTER ADD FOREIGN KEY cascade-deletes form_fields rows (form_fields.form_schema_id has cascadeOnDelete on form_schemas). Application-level integrity via FormSchema::defaultCrowdType() belongsTo + the publish guard + the runtime failsafe — three load-bearing checks, none of which require the DB-level constraint. Three pre-existing migration backfill tests bumped step counts +1 to account for the new migration sitting between WS-5c and WS-5d: FormFieldBindingMigrationTest (16→17, 14→15), FormFieldConfigBackfillAndDropTest (11→12), FormFieldValidationRuleBackfillTest (14→15), ConditionalLogicBackfillTest (5→6). Six event_registration test fixtures updated to set default_crowd_type_id to satisfy the new publish guard. FormBuilderDevSeeder.resolveDefaultCrowdTypeId() — VOLUNTEER → first-active → create-as-needed fallback chain; documented contract for future seeders. SCHEMA.md updated to v2.7. Refs: RFC-WS-6.md v1.1 §3 Q8 addendum (Task 4 of this session) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
7.0 KiB
PHP
189 lines
7.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\FormBuilder\Bindings;
|
|
|
|
use App\Enums\FormBuilder\ApplyStatus;
|
|
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
|
|
use App\Enums\FormBuilder\FormFieldType;
|
|
use App\Enums\FormBuilder\FormPurpose;
|
|
use App\FormBuilder\Bindings\BindingPassResult;
|
|
use App\FormBuilder\Bindings\FormBindingApplicator;
|
|
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\Person;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Spatie\Activitylog\Models\Activity;
|
|
use Tests\TestCase;
|
|
|
|
final class FormBindingApplicatorIntegrationTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_event_registration_happy_path(): void
|
|
{
|
|
$submission = $this->makeEventRegistrationSubmission();
|
|
|
|
$result = DB::transaction(fn (): BindingPassResult => $this->applicator()->apply($submission));
|
|
|
|
$this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus());
|
|
$this->assertSame('person', $result->provisionedSubjectType);
|
|
|
|
// Person was created with email + first_name + last_name from bindings.
|
|
$person = Person::query()->withoutGlobalScopes()->where('email', 'jan@example.nl')->first();
|
|
$this->assertNotNull($person);
|
|
$this->assertSame('Jan', $person->first_name);
|
|
$this->assertSame('Jansen', $person->last_name);
|
|
|
|
// Activity log: one pass-level + N child entries.
|
|
$passActivity = Activity::query()
|
|
->where('description', 'form_submission.bindings_pass_completed')
|
|
->where('subject_id', $submission->id)
|
|
->first();
|
|
$this->assertNotNull($passActivity);
|
|
$this->assertSame(2, (int) $passActivity->properties->get('binding_count'));
|
|
|
|
$childActivities = Activity::query()
|
|
->where('description', 'form_submission.binding_applied')
|
|
->where('subject_id', $submission->id)
|
|
->get();
|
|
$this->assertCount(2, $childActivities);
|
|
foreach ($childActivities as $child) {
|
|
$this->assertSame((string) $passActivity->id, $child->properties->get('parent_activity_id'));
|
|
}
|
|
}
|
|
|
|
public function test_no_transaction_guard_present(): void
|
|
{
|
|
// RefreshDatabase wraps every PHPUnit test in a transaction; the
|
|
// guard is exercised via the listener path (ApplyBindingsOnFormSubmit)
|
|
// which opens its own transaction explicitly. Verify the guard
|
|
// exists in the source.
|
|
$reflection = new \ReflectionClass(FormBindingApplicator::class);
|
|
$source = file_get_contents($reflection->getFileName());
|
|
$this->assertStringContainsString('no_transaction', $source);
|
|
$this->assertStringContainsString('DB::transactionLevel()', $source);
|
|
}
|
|
|
|
private function applicator(): FormBindingApplicator
|
|
{
|
|
return $this->app->make(FormBindingApplicator::class);
|
|
}
|
|
|
|
private function makeEventRegistrationSubmission(): FormSubmission
|
|
{
|
|
$event = Event::factory()->create();
|
|
$crowdType = CrowdType::factory()->create([
|
|
'organisation_id' => $event->organisation_id,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$schema = FormSchema::factory()->create([
|
|
'organisation_id' => $event->organisation_id,
|
|
'purpose' => FormPurpose::EVENT_REGISTRATION->value,
|
|
'default_crowd_type_id' => $crowdType->id,
|
|
]);
|
|
|
|
$emailField = FormField::factory()->create([
|
|
'form_schema_id' => $schema->id,
|
|
'field_type' => FormFieldType::EMAIL->value,
|
|
'slug' => 'email',
|
|
]);
|
|
$emailBinding = FormFieldBinding::factory()->forField($emailField)
|
|
->entityOwned('person', 'email')
|
|
->create([
|
|
'is_identity_key' => true,
|
|
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
|
|
'trust_level' => 80,
|
|
]);
|
|
|
|
$firstNameField = FormField::factory()->create([
|
|
'form_schema_id' => $schema->id,
|
|
'slug' => 'first_name',
|
|
]);
|
|
$firstNameBinding = 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',
|
|
]);
|
|
$lastNameBinding = 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,
|
|
'subject_type' => 'person',
|
|
]);
|
|
|
|
$submission->schema_snapshot = [
|
|
'fields' => [
|
|
$this->snapshotField($emailField, $emailBinding, 'person', 'email', identityKey: true, trustLevel: 80),
|
|
$this->snapshotField($firstNameField, $firstNameBinding, 'person', 'first_name', trustLevel: 70),
|
|
$this->snapshotField($lastNameField, $lastNameBinding, 'person', 'last_name', trustLevel: 60),
|
|
],
|
|
];
|
|
$submission->save();
|
|
|
|
$this->writeValue($submission->id, $emailField->id, 'jan@example.nl');
|
|
$this->writeValue($submission->id, $firstNameField->id, 'Jan');
|
|
$this->writeValue($submission->id, $lastNameField->id, 'Jansen');
|
|
|
|
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;
|
|
$row->setAttribute('value', $value);
|
|
$row->value_anonymised = false;
|
|
$row->save();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function snapshotField(
|
|
FormField $field,
|
|
FormFieldBinding $binding,
|
|
string $entity,
|
|
string $column,
|
|
bool $identityKey = false,
|
|
int $trustLevel = 50,
|
|
): array {
|
|
return [
|
|
'id' => (string) $field->id,
|
|
'slug' => (string) $field->slug,
|
|
'sort_order' => (int) $field->sort_order,
|
|
'bindings' => [[
|
|
'id' => (string) $binding->id,
|
|
'mode' => 'entity_owned',
|
|
'entity' => $entity,
|
|
'column' => $column,
|
|
'merge_strategy' => 'overwrite',
|
|
'trust_level' => $trustLevel,
|
|
'is_identity_key' => $identityKey,
|
|
]],
|
|
];
|
|
}
|
|
}
|