Files
crewli/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php
bert.hausmans f94b3fb329 feat(form-builder): exception hierarchy for binding-apply pipeline
Per RFC-WS-6 §Q3 v1.3 addition 2 (binding hierarchy) + §Q2 (invariant exception).

- Refactored FormBindingApplicatorException from concrete final to abstract
  base. Constructor (submissionId, message, previous?) preserves submissionId
  as a public readonly property so D2's outer-transaction handler can write
  it structurally to form_submission_action_failures.context JSON without
  regex-parsing the message. Replaced public-readonly reasonCode property
  with abstract reasonCode(): string method.
- Added 3 reason-coded subclasses:
  - FormBindingSchemaConfigException -> 'schema_config_error' (422)
  - FormBindingInfraException -> 'temporary_error' (503, NOT final because
    Timeout extends it)
  - FormBindingDataIntegrityException -> 'data_integrity_error' (422)
- Added FormBindingApplicatorTimeoutException extending FormBindingInfraException
  (timeout = temporary infra issue from user perspective; reasonCode inherited).
- Added IdentityMatchInvariantViolation as a sibling DomainException — NOT
  in the FormBindingApplicatorException hierarchy because it's thrown
  outside the binding-applicator pipeline.
- Migrated 3 existing throw sites in FormBindingApplicator::apply():
  - 'no_transaction' -> FormBindingInfraException (developer-error wants
    infra-triage workflow: GlitchTip alert + retry-after)
  - 'no_schema' -> FormBindingSchemaConfigException
  - 'unknown_purpose' -> FormBindingSchemaConfigException
- Updated FormBindingApplicatorIntegrationTest::test_no_transaction_guard_present
  to assert against the new throw shape (FormBindingInfraException + new
  message string) while preserving the test's intent (guard exists in source).

Wiring (deadline wrapper, classifier integration in listener catch +
retry-service recordFailure) lands in D2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:58:11 +02:00

193 lines
7.3 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 by checking for the throw of
// FormBindingInfraException (per RFC-WS-6 §Q3 v1.3 addition 2 — the
// 'no_transaction' developer-error maps onto temporary_error so the
// GlitchTip alert + retry-after workflow fires).
$reflection = new \ReflectionClass(FormBindingApplicator::class);
$source = file_get_contents($reflection->getFileName());
$this->assertStringContainsString('FormBindingInfraException', $source);
$this->assertStringContainsString('DB::transactionLevel()', $source);
$this->assertStringContainsString('must be invoked inside DB::transaction', $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,
]],
];
}
}