diff --git a/api/app/Enums/FormBuilder/FormFieldBindingMergeStrategy.php b/api/app/Enums/FormBuilder/FormFieldBindingMergeStrategy.php index 22171276..f66c0708 100644 --- a/api/app/Enums/FormBuilder/FormFieldBindingMergeStrategy.php +++ b/api/app/Enums/FormBuilder/FormFieldBindingMergeStrategy.php @@ -10,4 +10,31 @@ enum FormFieldBindingMergeStrategy: string case Append = 'append'; case Replace = 'replace'; case FirstWriteWins = 'first_write_wins'; + + /** + * RFC-WS-6 §3 (Q7) — what to do when the conflict-resolution winner + * has a null value. Returns one of: + * - 'write' write null to the target (intent: clear) + * - 'noop' leave the target untouched + * - 'conditional' write only when the target itself is null + */ + public function nullWinnerBehaviour(): string + { + return match ($this) { + self::Overwrite => 'write', + self::Append => 'noop', + self::Replace => 'noop', + self::FirstWriteWins => 'conditional', + }; + } + + /** + * RFC-WS-6 §4 (V1) — Append is collection-only (idempotent retry only + * with set semantics; scalar-append demands fingerprinting which is + * an architectural smell). + */ + public function isValidForScalarTargets(): bool + { + return $this !== self::Append; + } } diff --git a/api/app/FormBuilder/Bindings/BindingApplicationResult.php b/api/app/FormBuilder/Bindings/BindingApplicationResult.php new file mode 100644 index 00000000..a0ebe7ae --- /dev/null +++ b/api/app/FormBuilder/Bindings/BindingApplicationResult.php @@ -0,0 +1,61 @@ +getMessage(), + ); + } +} diff --git a/api/app/FormBuilder/Bindings/BindingPassResult.php b/api/app/FormBuilder/Bindings/BindingPassResult.php new file mode 100644 index 00000000..04cc8286 --- /dev/null +++ b/api/app/FormBuilder/Bindings/BindingPassResult.php @@ -0,0 +1,66 @@ + $applications + */ + public function __construct( + public string $formSubmissionId, + public ?string $provisionedSubjectType, + public ?string $provisionedSubjectId, + public array $applications, + ) {} + + public function applyStatus(): ApplyStatus + { + if ($this->applications === []) { + return ApplyStatus::COMPLETED; + } + + $successes = $this->successCount(); + $failures = $this->failureCount(); + + if ($failures === 0) { + return ApplyStatus::COMPLETED; + } + if ($successes === 0) { + return ApplyStatus::FAILED; + } + + return ApplyStatus::PARTIAL; + } + + public function successCount(): int + { + return count(array_filter($this->applications, fn (BindingApplicationResult $r): bool => $r->success)); + } + + public function failureCount(): int + { + return count(array_filter($this->applications, fn (BindingApplicationResult $r): bool => ! $r->success)); + } + + /** + * @return list + */ + public function failures(): array + { + return array_values(array_filter( + $this->applications, + fn (BindingApplicationResult $r): bool => ! $r->success, + )); + } +} diff --git a/api/app/FormBuilder/Bindings/ResolvedBinding.php b/api/app/FormBuilder/Bindings/ResolvedBinding.php new file mode 100644 index 00000000..dc2703a7 --- /dev/null +++ b/api/app/FormBuilder/Bindings/ResolvedBinding.php @@ -0,0 +1,42 @@ + 100) { + throw new InvalidArgumentException( + "trustLevel must be in [0,100]; got {$trustLevel}", + ); + } + if ($targetEntity === '') { + throw new InvalidArgumentException('targetEntity must not be empty'); + } + if ($targetAttribute === '') { + throw new InvalidArgumentException('targetAttribute must not be empty'); + } + } +} diff --git a/api/tests/Unit/Enums/FormBuilder/MergeStrategyTest.php b/api/tests/Unit/Enums/FormBuilder/MergeStrategyTest.php new file mode 100644 index 00000000..a77aa5b9 --- /dev/null +++ b/api/tests/Unit/Enums/FormBuilder/MergeStrategyTest.php @@ -0,0 +1,27 @@ +assertSame('write', FormFieldBindingMergeStrategy::Overwrite->nullWinnerBehaviour()); + $this->assertSame('noop', FormFieldBindingMergeStrategy::Append->nullWinnerBehaviour()); + $this->assertSame('noop', FormFieldBindingMergeStrategy::Replace->nullWinnerBehaviour()); + $this->assertSame('conditional', FormFieldBindingMergeStrategy::FirstWriteWins->nullWinnerBehaviour()); + } + + public function test_is_valid_for_scalar_targets_only_append_returns_false(): void + { + $this->assertTrue(FormFieldBindingMergeStrategy::Overwrite->isValidForScalarTargets()); + $this->assertFalse(FormFieldBindingMergeStrategy::Append->isValidForScalarTargets()); + $this->assertTrue(FormFieldBindingMergeStrategy::Replace->isValidForScalarTargets()); + $this->assertTrue(FormFieldBindingMergeStrategy::FirstWriteWins->isValidForScalarTargets()); + } +} diff --git a/api/tests/Unit/FormBuilder/Bindings/BindingApplicationResultTest.php b/api/tests/Unit/FormBuilder/Bindings/BindingApplicationResultTest.php new file mode 100644 index 00000000..1c43fc19 --- /dev/null +++ b/api/tests/Unit/FormBuilder/Bindings/BindingApplicationResultTest.php @@ -0,0 +1,51 @@ +assertTrue($result->success); + $this->assertSame('bnd-1', $result->bindingId); + $this->assertSame('person', $result->targetEntity); + $this->assertSame('email', $result->targetAttribute); + $this->assertNull($result->oldValue); + $this->assertSame('jan@example.nl', $result->newValue); + $this->assertNull($result->exceptionClass); + $this->assertNull($result->exceptionMessage); + } + + public function test_failed_constructor_captures_exception_metadata(): void + { + $exception = new RuntimeException('boom'); + + $result = BindingApplicationResult::failed( + bindingId: 'bnd-2', + targetEntity: 'company', + targetAttribute: 'name', + e: $exception, + ); + + $this->assertFalse($result->success); + $this->assertSame('bnd-2', $result->bindingId); + $this->assertNull($result->oldValue); + $this->assertNull($result->newValue); + $this->assertSame(RuntimeException::class, $result->exceptionClass); + $this->assertSame('boom', $result->exceptionMessage); + } +} diff --git a/api/tests/Unit/FormBuilder/Bindings/BindingPassResultTest.php b/api/tests/Unit/FormBuilder/Bindings/BindingPassResultTest.php new file mode 100644 index 00000000..be568560 --- /dev/null +++ b/api/tests/Unit/FormBuilder/Bindings/BindingPassResultTest.php @@ -0,0 +1,88 @@ +make([]); + $this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus()); + $this->assertSame(0, $result->successCount()); + $this->assertSame(0, $result->failureCount()); + } + + public function test_all_succeeded_yields_completed(): void + { + $result = $this->make([$this->success('a'), $this->success('b')]); + $this->assertSame(ApplyStatus::COMPLETED, $result->applyStatus()); + $this->assertSame(2, $result->successCount()); + $this->assertSame(0, $result->failureCount()); + $this->assertSame([], $result->failures()); + } + + public function test_all_failed_yields_failed(): void + { + $result = $this->make([$this->failure('a'), $this->failure('b')]); + $this->assertSame(ApplyStatus::FAILED, $result->applyStatus()); + $this->assertSame(0, $result->successCount()); + $this->assertSame(2, $result->failureCount()); + $this->assertCount(2, $result->failures()); + } + + public function test_mixed_yields_partial(): void + { + $result = $this->make([ + $this->success('a'), + $this->failure('b'), + $this->success('c'), + ]); + $this->assertSame(ApplyStatus::PARTIAL, $result->applyStatus()); + $this->assertSame(2, $result->successCount()); + $this->assertSame(1, $result->failureCount()); + $this->assertCount(1, $result->failures()); + $this->assertSame('b', $result->failures()[0]->bindingId); + } + + /** + * @param list $applications + */ + private function make(array $applications): BindingPassResult + { + return new BindingPassResult( + formSubmissionId: 'fs-1', + provisionedSubjectType: 'person', + provisionedSubjectId: 'p-1', + applications: $applications, + ); + } + + private function success(string $bindingId): BindingApplicationResult + { + return BindingApplicationResult::succeeded( + bindingId: $bindingId, + targetEntity: 'person', + targetAttribute: 'email', + oldValue: null, + newValue: 'x@y.z', + ); + } + + private function failure(string $bindingId): BindingApplicationResult + { + return BindingApplicationResult::failed( + bindingId: $bindingId, + targetEntity: 'person', + targetAttribute: 'email', + e: new RuntimeException('boom'), + ); + } +} diff --git a/api/tests/Unit/FormBuilder/Bindings/ResolvedBindingTest.php b/api/tests/Unit/FormBuilder/Bindings/ResolvedBindingTest.php new file mode 100644 index 00000000..ba45b215 --- /dev/null +++ b/api/tests/Unit/FormBuilder/Bindings/ResolvedBindingTest.php @@ -0,0 +1,101 @@ +makeWith(['trustLevel' => 80]); + $this->assertSame(80, $binding->trustLevel); + $this->assertSame('person', $binding->targetEntity); + } + + public function test_trust_level_lower_bound(): void + { + $binding = $this->makeWith(['trustLevel' => 0]); + $this->assertSame(0, $binding->trustLevel); + } + + public function test_trust_level_upper_bound(): void + { + $binding = $this->makeWith(['trustLevel' => 100]); + $this->assertSame(100, $binding->trustLevel); + } + + public function test_trust_level_below_zero_throws(): void + { + $this->expectException(InvalidArgumentException::class); + $this->makeWith(['trustLevel' => -1]); + } + + public function test_trust_level_above_hundred_throws(): void + { + $this->expectException(InvalidArgumentException::class); + $this->makeWith(['trustLevel' => 101]); + } + + public function test_empty_target_entity_throws(): void + { + $this->expectException(InvalidArgumentException::class); + $this->makeWith(['targetEntity' => '']); + } + + public function test_empty_target_attribute_throws(): void + { + $this->expectException(InvalidArgumentException::class); + $this->makeWith(['targetAttribute' => '']); + } + + public function test_null_value_preserved_with_explicit_flag(): void + { + $binding = $this->makeWith([ + 'value' => null, + 'valueIsExplicit' => true, + ]); + $this->assertNull($binding->value); + $this->assertTrue($binding->valueIsExplicit); + } + + /** + * @param array $overrides + */ + private function makeWith(array $overrides): ResolvedBinding + { + $defaults = [ + 'sourceFormFieldId' => '01jq1k1k1k1k1k1k1k1k1k1k1k', + 'bindingId' => '01jq1k1k1k1k1k1k1k1k1k1k1l', + 'targetEntity' => 'person', + 'targetAttribute' => 'email', + 'targetType' => BindingTargetType::SCALAR, + 'mergeStrategy' => FormFieldBindingMergeStrategy::Overwrite, + 'trustLevel' => 50, + 'isIdentityKey' => false, + 'value' => 'jan@example.nl', + 'valueIsExplicit' => true, + ]; + + $args = array_merge($defaults, $overrides); + + return new ResolvedBinding( + sourceFormFieldId: $args['sourceFormFieldId'], + bindingId: $args['bindingId'], + targetEntity: $args['targetEntity'], + targetAttribute: $args['targetAttribute'], + targetType: $args['targetType'], + mergeStrategy: $args['mergeStrategy'], + trustLevel: $args['trustLevel'], + isIdentityKey: $args['isIdentityKey'], + value: $args['value'], + valueIsExplicit: $args['valueIsExplicit'], + ); + } +}