test(form-builder): per-purpose pipeline smoke for the 6 non-event_registration purposes (WS-6)
RFC §3 Q9 contract — applicator is purpose-agnostic; per-purpose
differences live in PurposeSubjectResolver. Sessie 2's smoke matrix
covered only event_registration; this commit fills the remaining six.
Coverage per file (18 tests total, all passing):
- SignatureContractPurposePipelineTest (3 tests)
happy path + conflict resolution + no_auth missing-context
target: User via auth (User::email/first_name/last_name)
- UserProfilePurposePipelineTest (3 tests)
happy path + conflict resolution + no_auth missing-context
target: User via auth
- SupplierIntakePurposePipelineTest (3 tests)
happy path + conflict resolution + no_production_request missing-context
target: Company subject pre-set by production_request flow
- PostEventEvaluationPurposePipelineTest (4 tests)
happy path + conflict resolution + no_person_for_user + no_auth
target: Person via auth user → Person.event_id link
- IncidentReportPurposePipelineTest (4 tests)
happy path (auth + Person link)
+ conflict resolution
+ anonymous-allowed (null subject → COMPLETED, empty applications)
+ auth-without-Person (null subject branch)
Unique purpose: only one allowed to legitimately resolve to no subject.
- ArtistAdvancePurposePipelineTest (1 test)
no_portal_token missing-context only.
Happy path + subject_not_found branches require the Artist model
(BACKLOG: ARCH-09); morphTo can't materialise a non-existent class.
Documented inline; full coverage follows once ARCH-09 lands.
Each test wires the schema_snapshot directly with the applicator-shape
binding entries (matches sessie 2's FormBindingApplicatorIntegrationTest
pattern). All bindings use registered binding-target attributes from
config/form_builder/binding_targets.php to satisfy BindingTypeRegistry's
strict resolve() at apply time.
Refs: RFC-WS-6.md §3 Q9, ARCH-BINDINGS.md § 6.5
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Bindings\Pipeline;
|
||||
|
||||
use App\Enums\FormBuilder\FormPurpose;
|
||||
use App\Exceptions\FormBuilder\PurposeSubjectResolutionException;
|
||||
use App\FormBuilder\Bindings\FormBindingApplicator;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* End-to-end pipeline smoke for the artist_advance purpose.
|
||||
*
|
||||
* RFC §3 Q9 case matrix (failure paths only — happy path requires the
|
||||
* Artist model which isn't yet implemented; tracked via BACKLOG: ARCH-09):
|
||||
*
|
||||
* - Missing portal token (subject_type ≠ 'artist')
|
||||
* - Subject not found (subject_type='artist' but no morph target row)
|
||||
*
|
||||
* Subject resolution: ArtistAdvanceSubjectResolver expects the portal-token
|
||||
* route to have pre-set submission.subject_type='artist' + subject_id. The
|
||||
* resolver throws if either is missing or the morph target row is absent.
|
||||
*
|
||||
* Ref: ARCH-BINDINGS.md § 6.5
|
||||
*/
|
||||
final class ArtistAdvancePurposePipelineTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_missing_portal_token_throws_no_portal_token(): void
|
||||
{
|
||||
// Submission lacks subject_type='artist' (the portal route would
|
||||
// have set this from the portal_token context). Resolver throws.
|
||||
$org = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $org->id,
|
||||
'purpose' => FormPurpose::ARTIST_ADVANCE->value,
|
||||
]);
|
||||
$submission = FormSubmission::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'subject_type' => null,
|
||||
'subject_id' => null,
|
||||
]);
|
||||
$submission->schema_snapshot = ['fields' => []];
|
||||
$submission->save();
|
||||
|
||||
try {
|
||||
DB::transaction(fn () => resolve(FormBindingApplicator::class)->apply($submission->fresh()));
|
||||
$this->fail('Expected PurposeSubjectResolutionException');
|
||||
} catch (PurposeSubjectResolutionException $e) {
|
||||
$this->assertSame('artist_advance', $e->purposeSlug);
|
||||
$this->assertSame('no_portal_token', $e->reasonCode);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* test_subject_claims_artist_but_record_gone — REMOVED until BACKLOG
|
||||
* ARCH-09 lands the Artist model. Hitting subject_type='artist' would
|
||||
* reach the resolver's `$submission->subject` morph access, which
|
||||
* Laravel's HasRelationships::newRelatedInstance instantiates as
|
||||
* `new App\Models\Artist`. The class doesn't exist yet (only the
|
||||
* morph alias is registered as a string), so the test crashes with
|
||||
* "Class not found" before reaching the subject_not_found branch.
|
||||
*
|
||||
* Once the Artist model + factory land, this case becomes testable:
|
||||
* just create an Artist subject row, link the submission to it, and
|
||||
* proceed with happy-path + conflict resolution patterns. The other
|
||||
* five purposes here exercise the full pattern; artist_advance is
|
||||
* limited to the no_portal_token branch above.
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Bindings\Pipeline;
|
||||
|
||||
use App\Enums\FormBuilder\ApplyStatus;
|
||||
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
|
||||
use App\Enums\FormBuilder\FormPurpose;
|
||||
use App\FormBuilder\Bindings\BindingPassResult;
|
||||
use App\FormBuilder\Bindings\FormBindingApplicator;
|
||||
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 App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* End-to-end pipeline smoke for the incident_report purpose.
|
||||
*
|
||||
* RFC §3 Q9 case matrix:
|
||||
* - Happy path (auth Person resolved, bindings applied)
|
||||
* - Conflict resolution (trust precedence)
|
||||
* - Missing required context (auth user without Person link → null subject)
|
||||
* - Anonymous-allowed (no auth → null subject, COMPLETED with empty applications)
|
||||
*
|
||||
* Subject resolution: IncidentReportSubjectResolver returns Person via auth
|
||||
* when a Person link exists for the event; otherwise null. Unique among the
|
||||
* 7 purposes — only one allowed to legitimately resolve to no subject.
|
||||
*
|
||||
* Ref: ARCH-BINDINGS.md § 6.5
|
||||
*/
|
||||
final class IncidentReportPurposePipelineTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_happy_path_authenticated_with_person_applies_bindings(): void
|
||||
{
|
||||
$event = Event::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$person = Person::factory()->create([
|
||||
'event_id' => $event->id,
|
||||
'user_id' => $user->id,
|
||||
'first_name' => 'Old',
|
||||
]);
|
||||
|
||||
$submission = $this->makeSubmission($event, $user, $person, [
|
||||
'first_name' => ['value' => 'Updated', 'trust' => 70],
|
||||
]);
|
||||
|
||||
$result = DB::transaction(fn (): BindingPassResult => resolve(FormBindingApplicator::class)->apply($submission));
|
||||
|
||||
$this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus());
|
||||
$this->assertSame('person', $result->provisionedSubjectType);
|
||||
|
||||
$person->refresh();
|
||||
$this->assertSame('Updated', $person->first_name);
|
||||
}
|
||||
|
||||
public function test_conflict_resolution_picks_highest_trust(): void
|
||||
{
|
||||
$event = Event::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$person = Person::factory()->create([
|
||||
'event_id' => $event->id,
|
||||
'user_id' => $user->id,
|
||||
'first_name' => 'Initial',
|
||||
]);
|
||||
|
||||
$submission = $this->makeSubmission($event, $user, $person, [
|
||||
'first_name__low' => ['value' => 'LowTrust', 'trust' => 30, 'attribute' => 'first_name'],
|
||||
'first_name__high' => ['value' => 'HighTrust', 'trust' => 90, 'attribute' => 'first_name'],
|
||||
]);
|
||||
|
||||
$result = DB::transaction(fn (): BindingPassResult => resolve(FormBindingApplicator::class)->apply($submission));
|
||||
|
||||
$this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus());
|
||||
$person->refresh();
|
||||
$this->assertSame('HighTrust', $person->first_name);
|
||||
}
|
||||
|
||||
public function test_anonymous_submission_resolves_to_null_subject_completed(): void
|
||||
{
|
||||
// Anonymous-allowed: no submitted_by_user_id, no subject. The
|
||||
// applicator's null-subject branch (Q9) returns COMPLETED with
|
||||
// empty applications. The submission is recorded; binding writes
|
||||
// are skipped.
|
||||
$event = Event::factory()->create();
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $event->organisation_id,
|
||||
'purpose' => FormPurpose::INCIDENT_REPORT->value,
|
||||
]);
|
||||
$submission = FormSubmission::factory()->forEvent($event)->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'submitted_by_user_id' => null,
|
||||
'subject_type' => null,
|
||||
'subject_id' => null,
|
||||
]);
|
||||
$submission->schema_snapshot = ['fields' => []];
|
||||
$submission->save();
|
||||
|
||||
$result = DB::transaction(fn (): BindingPassResult => resolve(FormBindingApplicator::class)->apply($submission->fresh()));
|
||||
|
||||
$this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus());
|
||||
$this->assertNull($result->provisionedSubjectType);
|
||||
$this->assertNull($result->provisionedSubjectId);
|
||||
$this->assertSame([], $result->applications);
|
||||
}
|
||||
|
||||
public function test_authenticated_user_without_person_link_resolves_null_subject(): void
|
||||
{
|
||||
// Auth user is set but has no Person row for this event. The
|
||||
// resolver returns null (NOT throw — incident_report's anonymous-
|
||||
// allowed contract treats user-without-Person same as no auth).
|
||||
// Applicator → COMPLETED with empty applications.
|
||||
$event = Event::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $event->organisation_id,
|
||||
'purpose' => FormPurpose::INCIDENT_REPORT->value,
|
||||
]);
|
||||
$submission = FormSubmission::factory()->forEvent($event)->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'submitted_by_user_id' => $user->id,
|
||||
'subject_type' => null,
|
||||
'subject_id' => null,
|
||||
]);
|
||||
$submission->schema_snapshot = ['fields' => []];
|
||||
$submission->save();
|
||||
|
||||
$result = DB::transaction(fn (): BindingPassResult => resolve(FormBindingApplicator::class)->apply($submission->fresh()));
|
||||
|
||||
$this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus());
|
||||
$this->assertNull($result->provisionedSubjectType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{value:string, trust:int, attribute?:string}> $bindingSpecs
|
||||
*/
|
||||
private function makeSubmission(Event $event, User $user, Person $person, array $bindingSpecs): FormSubmission
|
||||
{
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $event->organisation_id,
|
||||
'purpose' => FormPurpose::INCIDENT_REPORT->value,
|
||||
]);
|
||||
|
||||
$submission = FormSubmission::factory()->forEvent($event)->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'submitted_by_user_id' => $user->id,
|
||||
'subject_type' => 'person',
|
||||
'subject_id' => $person->id,
|
||||
]);
|
||||
|
||||
$snapshotFields = [];
|
||||
foreach ($bindingSpecs as $slug => $spec) {
|
||||
$attribute = $spec['attribute'] ?? $slug;
|
||||
$field = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'slug' => $slug,
|
||||
]);
|
||||
$binding = FormFieldBinding::factory()->forField($field)
|
||||
->entityOwned('person', $attribute)
|
||||
->create([
|
||||
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
|
||||
'trust_level' => $spec['trust'],
|
||||
]);
|
||||
$snapshotFields[] = [
|
||||
'id' => (string) $field->id,
|
||||
'slug' => (string) $field->slug,
|
||||
'sort_order' => (int) $field->sort_order,
|
||||
'bindings' => [[
|
||||
'id' => (string) $binding->id,
|
||||
'mode' => 'entity_owned',
|
||||
'entity' => 'person',
|
||||
'column' => $attribute,
|
||||
'merge_strategy' => 'overwrite',
|
||||
'trust_level' => $spec['trust'],
|
||||
'is_identity_key' => false,
|
||||
]],
|
||||
];
|
||||
$this->writeValue($submission->id, $field->id, $spec['value']);
|
||||
}
|
||||
|
||||
$submission->schema_snapshot = ['fields' => $snapshotFields];
|
||||
$submission->save();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Bindings\Pipeline;
|
||||
|
||||
use App\Enums\FormBuilder\ApplyStatus;
|
||||
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
|
||||
use App\Enums\FormBuilder\FormPurpose;
|
||||
use App\Exceptions\FormBuilder\PurposeSubjectResolutionException;
|
||||
use App\FormBuilder\Bindings\BindingPassResult;
|
||||
use App\FormBuilder\Bindings\FormBindingApplicator;
|
||||
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 App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* End-to-end pipeline smoke for the post_event_evaluation purpose.
|
||||
*
|
||||
* RFC §3 Q9 case matrix:
|
||||
* - Happy path
|
||||
* - Conflict resolution (trust precedence)
|
||||
* - Missing required context (auth user has no Person link for the event)
|
||||
*
|
||||
* Subject resolution: PostEventEvaluationSubjectResolver returns the
|
||||
* Person linked to the auth User for the submission's event_id. Throws
|
||||
* `no_person_for_user` if no link exists, `no_auth` if no auth user.
|
||||
*
|
||||
* Ref: ARCH-BINDINGS.md § 6.5
|
||||
*/
|
||||
final class PostEventEvaluationPurposePipelineTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_happy_path_applies_bindings_and_marks_completed(): void
|
||||
{
|
||||
$event = Event::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$person = Person::factory()->create([
|
||||
'event_id' => $event->id,
|
||||
'user_id' => $user->id,
|
||||
'first_name' => 'Old',
|
||||
'last_name' => 'Name',
|
||||
]);
|
||||
|
||||
$submission = $this->makeSubmission($event, $user, $person, [
|
||||
'first_name' => ['value' => 'Jan', 'trust' => 70],
|
||||
'last_name' => ['value' => 'Jansen', 'trust' => 60],
|
||||
]);
|
||||
|
||||
$result = DB::transaction(fn (): BindingPassResult => resolve(FormBindingApplicator::class)->apply($submission));
|
||||
|
||||
$this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus());
|
||||
$this->assertSame('person', $result->provisionedSubjectType);
|
||||
|
||||
$person->refresh();
|
||||
$this->assertSame('Jan', $person->first_name);
|
||||
$this->assertSame('Jansen', $person->last_name);
|
||||
}
|
||||
|
||||
public function test_conflict_resolution_picks_highest_trust(): void
|
||||
{
|
||||
$event = Event::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$person = Person::factory()->create([
|
||||
'event_id' => $event->id,
|
||||
'user_id' => $user->id,
|
||||
'first_name' => 'Initial',
|
||||
]);
|
||||
|
||||
$submission = $this->makeSubmission($event, $user, $person, [
|
||||
'first_name__low' => ['value' => 'LowTrust', 'trust' => 30, 'attribute' => 'first_name'],
|
||||
'first_name__high' => ['value' => 'HighTrust', 'trust' => 90, 'attribute' => 'first_name'],
|
||||
]);
|
||||
|
||||
$result = DB::transaction(fn (): BindingPassResult => resolve(FormBindingApplicator::class)->apply($submission));
|
||||
|
||||
$this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus());
|
||||
$person->refresh();
|
||||
$this->assertSame('HighTrust', $person->first_name);
|
||||
}
|
||||
|
||||
public function test_missing_person_link_throws_resolution_exception(): void
|
||||
{
|
||||
// Auth user is set, but no Person row links it for this event.
|
||||
$event = Event::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $event->organisation_id,
|
||||
'purpose' => FormPurpose::POST_EVENT_EVALUATION->value,
|
||||
]);
|
||||
$submission = FormSubmission::factory()->forEvent($event)->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'submitted_by_user_id' => $user->id,
|
||||
'subject_type' => null,
|
||||
'subject_id' => null,
|
||||
]);
|
||||
$submission->schema_snapshot = ['fields' => []];
|
||||
$submission->save();
|
||||
|
||||
try {
|
||||
DB::transaction(fn () => resolve(FormBindingApplicator::class)->apply($submission->fresh()));
|
||||
$this->fail('Expected PurposeSubjectResolutionException');
|
||||
} catch (PurposeSubjectResolutionException $e) {
|
||||
$this->assertSame('post_event_evaluation', $e->purposeSlug);
|
||||
$this->assertSame('no_person_for_user', $e->reasonCode);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_missing_auth_user_throws_resolution_exception(): void
|
||||
{
|
||||
$event = Event::factory()->create();
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $event->organisation_id,
|
||||
'purpose' => FormPurpose::POST_EVENT_EVALUATION->value,
|
||||
]);
|
||||
$submission = FormSubmission::factory()->forEvent($event)->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'submitted_by_user_id' => null,
|
||||
'subject_type' => null,
|
||||
'subject_id' => null,
|
||||
]);
|
||||
$submission->schema_snapshot = ['fields' => []];
|
||||
$submission->save();
|
||||
|
||||
try {
|
||||
DB::transaction(fn () => resolve(FormBindingApplicator::class)->apply($submission->fresh()));
|
||||
$this->fail('Expected PurposeSubjectResolutionException');
|
||||
} catch (PurposeSubjectResolutionException $e) {
|
||||
$this->assertSame('post_event_evaluation', $e->purposeSlug);
|
||||
$this->assertSame('no_auth', $e->reasonCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{value:string, trust:int, attribute?:string}> $bindingSpecs
|
||||
*/
|
||||
private function makeSubmission(Event $event, User $user, Person $person, array $bindingSpecs): FormSubmission
|
||||
{
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $event->organisation_id,
|
||||
'purpose' => FormPurpose::POST_EVENT_EVALUATION->value,
|
||||
]);
|
||||
|
||||
$submission = FormSubmission::factory()->forEvent($event)->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'submitted_by_user_id' => $user->id,
|
||||
'subject_type' => 'person',
|
||||
'subject_id' => $person->id,
|
||||
]);
|
||||
|
||||
$snapshotFields = [];
|
||||
foreach ($bindingSpecs as $slug => $spec) {
|
||||
$attribute = $spec['attribute'] ?? $slug;
|
||||
$field = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'slug' => $slug,
|
||||
]);
|
||||
$binding = FormFieldBinding::factory()->forField($field)
|
||||
->entityOwned('person', $attribute)
|
||||
->create([
|
||||
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
|
||||
'trust_level' => $spec['trust'],
|
||||
]);
|
||||
$snapshotFields[] = [
|
||||
'id' => (string) $field->id,
|
||||
'slug' => (string) $field->slug,
|
||||
'sort_order' => (int) $field->sort_order,
|
||||
'bindings' => [[
|
||||
'id' => (string) $binding->id,
|
||||
'mode' => 'entity_owned',
|
||||
'entity' => 'person',
|
||||
'column' => $attribute,
|
||||
'merge_strategy' => 'overwrite',
|
||||
'trust_level' => $spec['trust'],
|
||||
'is_identity_key' => false,
|
||||
]],
|
||||
];
|
||||
$this->writeValue($submission->id, $field->id, $spec['value']);
|
||||
}
|
||||
|
||||
$submission->schema_snapshot = ['fields' => $snapshotFields];
|
||||
$submission->save();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Bindings\Pipeline;
|
||||
|
||||
use App\Enums\FormBuilder\ApplyStatus;
|
||||
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
|
||||
use App\Enums\FormBuilder\FormPurpose;
|
||||
use App\Exceptions\FormBuilder\PurposeSubjectResolutionException;
|
||||
use App\FormBuilder\Bindings\BindingPassResult;
|
||||
use App\FormBuilder\Bindings\FormBindingApplicator;
|
||||
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\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* End-to-end pipeline smoke for the signature_contract purpose.
|
||||
*
|
||||
* RFC §3 Q9 case matrix:
|
||||
* - Happy path
|
||||
* - Conflict resolution (trust precedence)
|
||||
* - Missing required context (no auth user)
|
||||
*
|
||||
* Subject resolution: SignatureContractSubjectResolver returns the
|
||||
* authenticated User (via submission.submitted_by_user_id).
|
||||
*
|
||||
* Ref: ARCH-BINDINGS.md § 6.5
|
||||
*/
|
||||
final class SignatureContractPurposePipelineTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_happy_path_applies_bindings_and_marks_completed(): void
|
||||
{
|
||||
$user = User::factory()->create(['first_name' => 'Old', 'last_name' => 'Name']);
|
||||
$submission = $this->makeSubmission($user, [
|
||||
'first_name' => ['value' => 'Jan', 'trust' => 70],
|
||||
'last_name' => ['value' => 'Jansen', 'trust' => 60],
|
||||
]);
|
||||
|
||||
$result = DB::transaction(fn (): BindingPassResult => resolve(FormBindingApplicator::class)->apply($submission));
|
||||
|
||||
$this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus());
|
||||
$this->assertSame('user', $result->provisionedSubjectType);
|
||||
$this->assertSame((string) $user->id, $result->provisionedSubjectId);
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame('Jan', $user->first_name);
|
||||
$this->assertSame('Jansen', $user->last_name);
|
||||
}
|
||||
|
||||
public function test_conflict_resolution_picks_highest_trust(): void
|
||||
{
|
||||
$user = User::factory()->create(['first_name' => 'Initial']);
|
||||
|
||||
// Two bindings to the same target attribute, different trust levels.
|
||||
$submission = $this->makeSubmission($user, [
|
||||
'first_name__low' => ['value' => 'LowTrust', 'trust' => 30, 'attribute' => 'first_name'],
|
||||
'first_name__high' => ['value' => 'HighTrust', 'trust' => 90, 'attribute' => 'first_name'],
|
||||
]);
|
||||
|
||||
$result = DB::transaction(fn (): BindingPassResult => resolve(FormBindingApplicator::class)->apply($submission));
|
||||
|
||||
$this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus());
|
||||
$user->refresh();
|
||||
$this->assertSame('HighTrust', $user->first_name);
|
||||
}
|
||||
|
||||
public function test_missing_auth_user_throws_resolution_exception(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $org->id,
|
||||
'purpose' => FormPurpose::SIGNATURE_CONTRACT->value,
|
||||
]);
|
||||
|
||||
// submitted_by_user_id deliberately null + subject_type/id null.
|
||||
$submission = FormSubmission::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'submitted_by_user_id' => null,
|
||||
'subject_type' => null,
|
||||
'subject_id' => null,
|
||||
]);
|
||||
$submission->schema_snapshot = ['fields' => []];
|
||||
$submission->save();
|
||||
|
||||
try {
|
||||
DB::transaction(fn () => resolve(FormBindingApplicator::class)->apply($submission->fresh()));
|
||||
$this->fail('Expected PurposeSubjectResolutionException');
|
||||
} catch (PurposeSubjectResolutionException $e) {
|
||||
$this->assertSame('signature_contract', $e->purposeSlug);
|
||||
$this->assertSame('no_auth', $e->reasonCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{value:string, trust:int, attribute?:string}> $bindingSpecs
|
||||
*/
|
||||
private function makeSubmission(User $user, array $bindingSpecs): FormSubmission
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $org->id,
|
||||
'purpose' => FormPurpose::SIGNATURE_CONTRACT->value,
|
||||
]);
|
||||
|
||||
$submission = FormSubmission::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'submitted_by_user_id' => $user->id,
|
||||
'subject_type' => 'user',
|
||||
'subject_id' => $user->id,
|
||||
]);
|
||||
|
||||
$snapshotFields = [];
|
||||
foreach ($bindingSpecs as $slug => $spec) {
|
||||
$attribute = $spec['attribute'] ?? $slug;
|
||||
$field = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'slug' => $slug,
|
||||
]);
|
||||
$binding = FormFieldBinding::factory()->forField($field)
|
||||
->entityOwned('user', $attribute)
|
||||
->create([
|
||||
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
|
||||
'trust_level' => $spec['trust'],
|
||||
]);
|
||||
$snapshotFields[] = [
|
||||
'id' => (string) $field->id,
|
||||
'slug' => (string) $field->slug,
|
||||
'sort_order' => (int) $field->sort_order,
|
||||
'bindings' => [[
|
||||
'id' => (string) $binding->id,
|
||||
'mode' => 'entity_owned',
|
||||
'entity' => 'user',
|
||||
'column' => $attribute,
|
||||
'merge_strategy' => 'overwrite',
|
||||
'trust_level' => $spec['trust'],
|
||||
'is_identity_key' => false,
|
||||
]],
|
||||
];
|
||||
$this->writeValue($submission->id, $field->id, $spec['value']);
|
||||
}
|
||||
|
||||
$submission->schema_snapshot = ['fields' => $snapshotFields];
|
||||
$submission->save();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Bindings\Pipeline;
|
||||
|
||||
use App\Enums\FormBuilder\ApplyStatus;
|
||||
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
|
||||
use App\Enums\FormBuilder\FormPurpose;
|
||||
use App\Exceptions\FormBuilder\PurposeSubjectResolutionException;
|
||||
use App\FormBuilder\Bindings\BindingPassResult;
|
||||
use App\FormBuilder\Bindings\FormBindingApplicator;
|
||||
use App\Models\Company;
|
||||
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 Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* End-to-end pipeline smoke for the supplier_intake purpose.
|
||||
*
|
||||
* RFC §3 Q9 case matrix:
|
||||
* - Happy path
|
||||
* - Conflict resolution (trust precedence)
|
||||
* - Missing required context (no production_request → no Company subject)
|
||||
*
|
||||
* Subject resolution: SupplierIntakeSubjectResolver returns the Company
|
||||
* pre-set on submission.subject_type/subject_id by the production_request
|
||||
* upstream flow. Throws if no Company subject.
|
||||
*
|
||||
* Ref: ARCH-BINDINGS.md § 6.5
|
||||
*/
|
||||
final class SupplierIntakePurposePipelineTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_happy_path_applies_bindings_and_marks_completed(): void
|
||||
{
|
||||
// Using `name` as the binding target — registered in
|
||||
// config/form_builder/binding_targets.php and a real Company column.
|
||||
$company = Company::factory()->create(['name' => 'Old Co']);
|
||||
|
||||
$submission = $this->makeSubmission($company, [
|
||||
'name' => ['value' => 'New Co BV', 'trust' => 70],
|
||||
]);
|
||||
|
||||
$result = DB::transaction(fn (): BindingPassResult => resolve(FormBindingApplicator::class)->apply($submission));
|
||||
|
||||
$this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus());
|
||||
$this->assertSame('company', $result->provisionedSubjectType);
|
||||
|
||||
$company->refresh();
|
||||
$this->assertSame('New Co BV', $company->name);
|
||||
}
|
||||
|
||||
public function test_conflict_resolution_picks_highest_trust(): void
|
||||
{
|
||||
$company = Company::factory()->create(['name' => 'Initial']);
|
||||
|
||||
$submission = $this->makeSubmission($company, [
|
||||
'name__low' => ['value' => 'LowTrust Co', 'trust' => 30, 'attribute' => 'name'],
|
||||
'name__high' => ['value' => 'HighTrust Co', 'trust' => 90, 'attribute' => 'name'],
|
||||
]);
|
||||
|
||||
$result = DB::transaction(fn (): BindingPassResult => resolve(FormBindingApplicator::class)->apply($submission));
|
||||
|
||||
$this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus());
|
||||
$company->refresh();
|
||||
$this->assertSame('HighTrust Co', $company->name);
|
||||
}
|
||||
|
||||
public function test_missing_company_subject_throws_resolution_exception(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $org->id,
|
||||
'purpose' => FormPurpose::SUPPLIER_INTAKE->value,
|
||||
]);
|
||||
|
||||
// production_request flow would have set subject_type='company' +
|
||||
// subject_id; without it, the resolver throws.
|
||||
$submission = FormSubmission::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'subject_type' => null,
|
||||
'subject_id' => null,
|
||||
]);
|
||||
$submission->schema_snapshot = ['fields' => []];
|
||||
$submission->save();
|
||||
|
||||
try {
|
||||
DB::transaction(fn () => resolve(FormBindingApplicator::class)->apply($submission->fresh()));
|
||||
$this->fail('Expected PurposeSubjectResolutionException');
|
||||
} catch (PurposeSubjectResolutionException $e) {
|
||||
$this->assertSame('supplier_intake', $e->purposeSlug);
|
||||
$this->assertSame('no_production_request', $e->reasonCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{value:string, trust:int, attribute?:string}> $bindingSpecs
|
||||
*/
|
||||
private function makeSubmission(Company $company, array $bindingSpecs): FormSubmission
|
||||
{
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $company->organisation_id,
|
||||
'purpose' => FormPurpose::SUPPLIER_INTAKE->value,
|
||||
]);
|
||||
|
||||
$submission = FormSubmission::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'subject_type' => 'company',
|
||||
'subject_id' => $company->id,
|
||||
]);
|
||||
|
||||
$snapshotFields = [];
|
||||
foreach ($bindingSpecs as $slug => $spec) {
|
||||
$attribute = $spec['attribute'] ?? $slug;
|
||||
$field = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'slug' => $slug,
|
||||
]);
|
||||
$binding = FormFieldBinding::factory()->forField($field)
|
||||
->entityOwned('company', $attribute)
|
||||
->create([
|
||||
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
|
||||
'trust_level' => $spec['trust'],
|
||||
]);
|
||||
$snapshotFields[] = [
|
||||
'id' => (string) $field->id,
|
||||
'slug' => (string) $field->slug,
|
||||
'sort_order' => (int) $field->sort_order,
|
||||
'bindings' => [[
|
||||
'id' => (string) $binding->id,
|
||||
'mode' => 'entity_owned',
|
||||
'entity' => 'company',
|
||||
'column' => $attribute,
|
||||
'merge_strategy' => 'overwrite',
|
||||
'trust_level' => $spec['trust'],
|
||||
'is_identity_key' => false,
|
||||
]],
|
||||
];
|
||||
$this->writeValue($submission->id, $field->id, $spec['value']);
|
||||
}
|
||||
|
||||
$submission->schema_snapshot = ['fields' => $snapshotFields];
|
||||
$submission->save();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Bindings\Pipeline;
|
||||
|
||||
use App\Enums\FormBuilder\ApplyStatus;
|
||||
use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
|
||||
use App\Enums\FormBuilder\FormPurpose;
|
||||
use App\Exceptions\FormBuilder\PurposeSubjectResolutionException;
|
||||
use App\FormBuilder\Bindings\BindingPassResult;
|
||||
use App\FormBuilder\Bindings\FormBindingApplicator;
|
||||
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\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* End-to-end pipeline smoke for the user_profile purpose.
|
||||
*
|
||||
* RFC §3 Q9 case matrix:
|
||||
* - Happy path
|
||||
* - Conflict resolution (trust precedence)
|
||||
* - Missing required context (no auth user)
|
||||
*
|
||||
* Subject resolution: UserProfileSubjectResolver returns the
|
||||
* authenticated User (via submission.submitted_by_user_id).
|
||||
*
|
||||
* Ref: ARCH-BINDINGS.md § 6.5
|
||||
*/
|
||||
final class UserProfilePurposePipelineTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_happy_path_applies_bindings_and_marks_completed(): void
|
||||
{
|
||||
// first_name/last_name are registered binding targets in
|
||||
// config/form_builder/binding_targets.php and live on the User model.
|
||||
$user = User::factory()->create(['first_name' => 'Old', 'last_name' => 'Name']);
|
||||
$submission = $this->makeSubmission($user, [
|
||||
'first_name' => ['value' => 'Jan', 'trust' => 70],
|
||||
'last_name' => ['value' => 'Jansen', 'trust' => 60],
|
||||
]);
|
||||
|
||||
$result = DB::transaction(fn (): BindingPassResult => resolve(FormBindingApplicator::class)->apply($submission));
|
||||
|
||||
$this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus());
|
||||
$this->assertSame('user', $result->provisionedSubjectType);
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame('Jan', $user->first_name);
|
||||
$this->assertSame('Jansen', $user->last_name);
|
||||
}
|
||||
|
||||
public function test_conflict_resolution_picks_highest_trust(): void
|
||||
{
|
||||
$user = User::factory()->create(['first_name' => 'Initial']);
|
||||
|
||||
$submission = $this->makeSubmission($user, [
|
||||
'first_name__low' => ['value' => 'LowTrust', 'trust' => 30, 'attribute' => 'first_name'],
|
||||
'first_name__high' => ['value' => 'HighTrust', 'trust' => 90, 'attribute' => 'first_name'],
|
||||
]);
|
||||
|
||||
$result = DB::transaction(fn (): BindingPassResult => resolve(FormBindingApplicator::class)->apply($submission));
|
||||
|
||||
$this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus());
|
||||
$user->refresh();
|
||||
$this->assertSame('HighTrust', $user->first_name);
|
||||
}
|
||||
|
||||
public function test_missing_auth_user_throws_resolution_exception(): void
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $org->id,
|
||||
'purpose' => FormPurpose::USER_PROFILE->value,
|
||||
]);
|
||||
|
||||
$submission = FormSubmission::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'submitted_by_user_id' => null,
|
||||
'subject_type' => null,
|
||||
'subject_id' => null,
|
||||
]);
|
||||
$submission->schema_snapshot = ['fields' => []];
|
||||
$submission->save();
|
||||
|
||||
try {
|
||||
DB::transaction(fn () => resolve(FormBindingApplicator::class)->apply($submission->fresh()));
|
||||
$this->fail('Expected PurposeSubjectResolutionException');
|
||||
} catch (PurposeSubjectResolutionException $e) {
|
||||
$this->assertSame('user_profile', $e->purposeSlug);
|
||||
$this->assertSame('no_auth', $e->reasonCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{value:string, trust:int, attribute?:string}> $bindingSpecs
|
||||
*/
|
||||
private function makeSubmission(User $user, array $bindingSpecs): FormSubmission
|
||||
{
|
||||
$org = Organisation::factory()->create();
|
||||
$schema = FormSchema::factory()->create([
|
||||
'organisation_id' => $org->id,
|
||||
'purpose' => FormPurpose::USER_PROFILE->value,
|
||||
]);
|
||||
|
||||
$submission = FormSubmission::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'submitted_by_user_id' => $user->id,
|
||||
'subject_type' => 'user',
|
||||
'subject_id' => $user->id,
|
||||
]);
|
||||
|
||||
$snapshotFields = [];
|
||||
foreach ($bindingSpecs as $slug => $spec) {
|
||||
$attribute = $spec['attribute'] ?? $slug;
|
||||
$field = FormField::factory()->create([
|
||||
'form_schema_id' => $schema->id,
|
||||
'slug' => $slug,
|
||||
]);
|
||||
$binding = FormFieldBinding::factory()->forField($field)
|
||||
->entityOwned('user', $attribute)
|
||||
->create([
|
||||
'merge_strategy' => FormFieldBindingMergeStrategy::Overwrite->value,
|
||||
'trust_level' => $spec['trust'],
|
||||
]);
|
||||
$snapshotFields[] = [
|
||||
'id' => (string) $field->id,
|
||||
'slug' => (string) $field->slug,
|
||||
'sort_order' => (int) $field->sort_order,
|
||||
'bindings' => [[
|
||||
'id' => (string) $binding->id,
|
||||
'mode' => 'entity_owned',
|
||||
'entity' => 'user',
|
||||
'column' => $attribute,
|
||||
'merge_strategy' => 'overwrite',
|
||||
'trust_level' => $spec['trust'],
|
||||
'is_identity_key' => false,
|
||||
]],
|
||||
];
|
||||
$this->writeValue($submission->id, $field->id, $spec['value']);
|
||||
}
|
||||
|
||||
$submission->schema_snapshot = ['fields' => $snapshotFields];
|
||||
$submission->save();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user