From 842cb01b3c65c39f1c535ec26127cf9cc6fbf687 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Tue, 28 Apr 2026 16:23:37 +0200 Subject: [PATCH] test(form-builder): per-purpose pipeline smoke for the 6 non-event_registration purposes (WS-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ArtistAdvancePurposePipelineTest.php | 77 +++++++ .../IncidentReportPurposePipelineTest.php | 206 ++++++++++++++++++ ...PostEventEvaluationPurposePipelineTest.php | 206 ++++++++++++++++++ .../SignatureContractPurposePipelineTest.php | 167 ++++++++++++++ .../SupplierIntakePurposePipelineTest.php | 165 ++++++++++++++ .../UserProfilePurposePipelineTest.php | 166 ++++++++++++++ 6 files changed, 987 insertions(+) create mode 100644 api/tests/Feature/FormBuilder/Bindings/Pipeline/ArtistAdvancePurposePipelineTest.php create mode 100644 api/tests/Feature/FormBuilder/Bindings/Pipeline/IncidentReportPurposePipelineTest.php create mode 100644 api/tests/Feature/FormBuilder/Bindings/Pipeline/PostEventEvaluationPurposePipelineTest.php create mode 100644 api/tests/Feature/FormBuilder/Bindings/Pipeline/SignatureContractPurposePipelineTest.php create mode 100644 api/tests/Feature/FormBuilder/Bindings/Pipeline/SupplierIntakePurposePipelineTest.php create mode 100644 api/tests/Feature/FormBuilder/Bindings/Pipeline/UserProfilePurposePipelineTest.php diff --git a/api/tests/Feature/FormBuilder/Bindings/Pipeline/ArtistAdvancePurposePipelineTest.php b/api/tests/Feature/FormBuilder/Bindings/Pipeline/ArtistAdvancePurposePipelineTest.php new file mode 100644 index 00000000..03efd707 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/Pipeline/ArtistAdvancePurposePipelineTest.php @@ -0,0 +1,77 @@ +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. + */ +} diff --git a/api/tests/Feature/FormBuilder/Bindings/Pipeline/IncidentReportPurposePipelineTest.php b/api/tests/Feature/FormBuilder/Bindings/Pipeline/IncidentReportPurposePipelineTest.php new file mode 100644 index 00000000..3e63acb0 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/Pipeline/IncidentReportPurposePipelineTest.php @@ -0,0 +1,206 @@ +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 $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(); + } +} diff --git a/api/tests/Feature/FormBuilder/Bindings/Pipeline/PostEventEvaluationPurposePipelineTest.php b/api/tests/Feature/FormBuilder/Bindings/Pipeline/PostEventEvaluationPurposePipelineTest.php new file mode 100644 index 00000000..e59a97b0 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/Pipeline/PostEventEvaluationPurposePipelineTest.php @@ -0,0 +1,206 @@ +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 $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(); + } +} diff --git a/api/tests/Feature/FormBuilder/Bindings/Pipeline/SignatureContractPurposePipelineTest.php b/api/tests/Feature/FormBuilder/Bindings/Pipeline/SignatureContractPurposePipelineTest.php new file mode 100644 index 00000000..99af726b --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/Pipeline/SignatureContractPurposePipelineTest.php @@ -0,0 +1,167 @@ +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 $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(); + } +} diff --git a/api/tests/Feature/FormBuilder/Bindings/Pipeline/SupplierIntakePurposePipelineTest.php b/api/tests/Feature/FormBuilder/Bindings/Pipeline/SupplierIntakePurposePipelineTest.php new file mode 100644 index 00000000..ef011177 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/Pipeline/SupplierIntakePurposePipelineTest.php @@ -0,0 +1,165 @@ +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 $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(); + } +} diff --git a/api/tests/Feature/FormBuilder/Bindings/Pipeline/UserProfilePurposePipelineTest.php b/api/tests/Feature/FormBuilder/Bindings/Pipeline/UserProfilePurposePipelineTest.php new file mode 100644 index 00000000..2a91098a --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/Pipeline/UserProfilePurposePipelineTest.php @@ -0,0 +1,166 @@ +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 $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(); + } +}