diff --git a/api/phpstan-baseline.neon b/api/phpstan-baseline.neon index f883d7ce..0478310e 100644 --- a/api/phpstan-baseline.neon +++ b/api/phpstan-baseline.neon @@ -7404,6 +7404,54 @@ parameters: count: 1 path: tests/Unit/ExampleTest.php + - + message: '#^Call to function is_subclass_of\(\) with ''App\\\\Exceptions\\\\FormBuilder\\\\FormBindingApplicatorException'' and ''RuntimeException'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: tests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.php + + - + message: '#^Call to function is_subclass_of\(\) with ''App\\\\Exceptions\\\\FormBuilder\\\\FormBindingApplicatorTimeoutException'' and ''App\\\\Exceptions\\\\FormBuilder\\\\FormBindingInfraException'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: tests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.php + + - + message: '#^Call to function is_subclass_of\(\) with ''App\\\\Exceptions\\\\FormBuilder\\\\FormBindingApplicatorTimeoutException''\|''App\\\\Exceptions\\\\FormBuilder\\\\FormBindingDataIntegrityException''\|''App\\\\Exceptions\\\\FormBuilder\\\\FormBindingInfraException''\|''App\\\\Exceptions\\\\FormBuilder\\\\FormBindingSchemaConfigException'' and ''App\\\\Exceptions\\\\FormBuilder\\\\FormBindingApplicatorException'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: tests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.php + + - + message: '#^Call to function is_subclass_of\(\) with ''App\\\\Exceptions\\\\FormBuilder\\\\IdentityMatchInvariantViolation'' and ''App\\\\Exceptions\\\\FormBuilder\\\\FormBindingApplicatorException'' will always evaluate to false\.$#' + identifier: function.impossibleType + count: 1 + path: tests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.php + + - + message: '#^Call to function is_subclass_of\(\) with ''App\\\\Exceptions\\\\FormBuilder\\\\IdentityMatchInvariantViolation'' and ''DomainException'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: tests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertFalse\(\) with false will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true and ''Class App…'' will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 3 + path: tests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.php + - message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#' identifier: property.notFound diff --git a/api/tests/Feature/FormBuilder/FormSubmissionFailureResponseCodeTest.php b/api/tests/Feature/FormBuilder/FormSubmissionFailureResponseCodeTest.php new file mode 100644 index 00000000..9f4f6492 --- /dev/null +++ b/api/tests/Feature/FormBuilder/FormSubmissionFailureResponseCodeTest.php @@ -0,0 +1,61 @@ +withFailureResponseCode('schema_config_error') + ->create(); + + $reloaded = $submission->fresh(); + $this->assertSame('schema_config_error', $reloaded->failure_response_code); + $this->assertIsString($reloaded->failure_response_code); + } + + public function test_failure_response_code_null_by_default(): void + { + $submission = FormSubmission::factory()->create(); + $this->assertNull($submission->fresh()->failure_response_code); + } + + public function test_factory_state_composes_with_apply_status(): void + { + $submission = FormSubmission::factory() + ->withFailureResponseCode('temporary_error') + ->create(['apply_status' => ApplyStatus::FAILED]); + + $reloaded = $submission->fresh(); + $this->assertSame(ApplyStatus::FAILED, $reloaded->apply_status); + $this->assertSame('temporary_error', $reloaded->failure_response_code); + } + + public function test_failure_response_code_round_trips_for_each_canonical_value(): void + { + foreach (['schema_config_error', 'temporary_error', 'data_integrity_error', 'unknown_error'] as $code) { + $submission = FormSubmission::factory() + ->withFailureResponseCode($code) + ->create(); + + $this->assertSame($code, $submission->fresh()->failure_response_code); + } + } +} diff --git a/api/tests/Feature/FormBuilder/Schema/Ws6V13DeltaD1MigrationTest.php b/api/tests/Feature/FormBuilder/Schema/Ws6V13DeltaD1MigrationTest.php new file mode 100644 index 00000000..0c3c18ec --- /dev/null +++ b/api/tests/Feature/FormBuilder/Schema/Ws6V13DeltaD1MigrationTest.php @@ -0,0 +1,59 @@ +assertTrue(Schema::hasColumn('form_submissions', 'failure_response_code')); + } + + public function test_failure_response_code_is_nullable_string_40(): void + { + $row = DB::selectOne( + 'SELECT DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, COLUMN_DEFAULT + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ?', + ['form_submissions', 'failure_response_code'], + ); + + $this->assertNotNull($row, 'failure_response_code column missing'); + $this->assertSame('varchar', strtolower((string) $row->DATA_TYPE)); + $this->assertSame(40, (int) $row->CHARACTER_MAXIMUM_LENGTH); + $this->assertSame('YES', $row->IS_NULLABLE); + $this->assertNull($row->COLUMN_DEFAULT); + } + + public function test_failure_response_code_index_present(): void + { + $row = DB::selectOne( + 'SELECT INDEX_NAME + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND INDEX_NAME = ?', + ['form_submissions', 'fs_failure_response_code_idx'], + ); + + $this->assertNotNull($row, 'fs_failure_response_code_idx missing'); + } +} diff --git a/api/tests/Unit/Enums/FormBuilder/FormFieldBindingMergeStrategyValidForTargetTypeTest.php b/api/tests/Unit/Enums/FormBuilder/FormFieldBindingMergeStrategyValidForTargetTypeTest.php new file mode 100644 index 00000000..7ee41e3c --- /dev/null +++ b/api/tests/Unit/Enums/FormBuilder/FormFieldBindingMergeStrategyValidForTargetTypeTest.php @@ -0,0 +1,47 @@ +assertTrue(FormFieldBindingMergeStrategy::Overwrite->validForTargetType(BindingTargetType::SCALAR)); + $this->assertTrue(FormFieldBindingMergeStrategy::Overwrite->validForTargetType(BindingTargetType::COLLECTION)); + $this->assertTrue(FormFieldBindingMergeStrategy::Overwrite->validForTargetType(BindingTargetType::RELATION)); + } + + public function test_replace_valid_for_all_target_types(): void + { + $this->assertTrue(FormFieldBindingMergeStrategy::Replace->validForTargetType(BindingTargetType::SCALAR)); + $this->assertTrue(FormFieldBindingMergeStrategy::Replace->validForTargetType(BindingTargetType::COLLECTION)); + $this->assertTrue(FormFieldBindingMergeStrategy::Replace->validForTargetType(BindingTargetType::RELATION)); + } + + public function test_first_write_wins_valid_for_all_target_types(): void + { + $this->assertTrue(FormFieldBindingMergeStrategy::FirstWriteWins->validForTargetType(BindingTargetType::SCALAR)); + $this->assertTrue(FormFieldBindingMergeStrategy::FirstWriteWins->validForTargetType(BindingTargetType::COLLECTION)); + $this->assertTrue(FormFieldBindingMergeStrategy::FirstWriteWins->validForTargetType(BindingTargetType::RELATION)); + } + + public function test_append_valid_only_for_collection(): void + { + $this->assertFalse(FormFieldBindingMergeStrategy::Append->validForTargetType(BindingTargetType::SCALAR)); + $this->assertTrue(FormFieldBindingMergeStrategy::Append->validForTargetType(BindingTargetType::COLLECTION)); + $this->assertFalse(FormFieldBindingMergeStrategy::Append->validForTargetType(BindingTargetType::RELATION)); + } +} diff --git a/api/tests/Unit/Events/FormBuilder/FormSubmissionIdentityMatchResolvedTest.php b/api/tests/Unit/Events/FormBuilder/FormSubmissionIdentityMatchResolvedTest.php new file mode 100644 index 00000000..8ca38e62 --- /dev/null +++ b/api/tests/Unit/Events/FormBuilder/FormSubmissionIdentityMatchResolvedTest.php @@ -0,0 +1,54 @@ +assertInstanceOf(ShouldBroadcast::class, $event); + } + + public function test_broadcasts_on_private_submission_channel(): void + { + $event = new FormSubmissionIdentityMatchResolved('01HX1234567890', 'matched', 2); + $channels = $event->broadcastOn(); + + $this->assertCount(1, $channels); + $this->assertInstanceOf(PrivateChannel::class, $channels[0]); + // PrivateChannel prepends 'private-' to the name passed to its + // constructor; that's the wire-format the frontend Echo client + // subscribes to. + $this->assertSame('private-submission.01HX1234567890', $channels[0]->name); + } + + public function test_broadcast_as(): void + { + $event = new FormSubmissionIdentityMatchResolved('01HX', 'matched', 1); + $this->assertSame('identity-match.resolved', $event->broadcastAs()); + } + + public function test_constructor_assigns_payload_readonly(): void + { + $event = new FormSubmissionIdentityMatchResolved('01HX', 'no_match', 0); + + $this->assertSame('01HX', $event->submissionId); + $this->assertSame('no_match', $event->status); + $this->assertSame(0, $event->matchCount); + } +} diff --git a/api/tests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.php b/api/tests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.php new file mode 100644 index 00000000..fa77afc3 --- /dev/null +++ b/api/tests/Unit/Exceptions/FormBuilder/FormBindingApplicatorExceptionHierarchyTest.php @@ -0,0 +1,139 @@ +assertTrue($reflection->isAbstract()); + } + + public function test_base_extends_runtime_exception(): void + { + $this->assertTrue(is_subclass_of(FormBindingApplicatorException::class, RuntimeException::class)); + } + + public function test_schema_config_exception_constructor_and_reason_code(): void + { + $e = new FormBindingSchemaConfigException( + submissionId: '01HX1234567890ABCDEFGHJKMN', + message: 'schema null', + ); + + $this->assertSame('01HX1234567890ABCDEFGHJKMN', $e->submissionId); + $this->assertSame('schema null', $e->getMessage()); + $this->assertSame('schema_config_error', $e->reasonCode()); + } + + public function test_infra_exception_constructor_and_reason_code(): void + { + $e = new FormBindingInfraException( + submissionId: '01HX1234567890ABCDEFGHJKMN', + message: 'no transaction', + ); + + $this->assertSame('01HX1234567890ABCDEFGHJKMN', $e->submissionId); + $this->assertSame('no transaction', $e->getMessage()); + $this->assertSame('temporary_error', $e->reasonCode()); + } + + public function test_data_integrity_exception_constructor_and_reason_code(): void + { + $e = new FormBindingDataIntegrityException( + submissionId: '01HX1234567890ABCDEFGHJKMN', + message: 'fk violation', + ); + + $this->assertSame('01HX1234567890ABCDEFGHJKMN', $e->submissionId); + $this->assertSame('fk violation', $e->getMessage()); + $this->assertSame('data_integrity_error', $e->reasonCode()); + } + + public function test_timeout_exception_constructor_and_inherited_reason_code(): void + { + $e = new FormBindingApplicatorTimeoutException( + submissionId: '01HX1234567890ABCDEFGHJKMN', + message: 'deadline exceeded after 5s', + ); + + $this->assertSame('01HX1234567890ABCDEFGHJKMN', $e->submissionId); + $this->assertSame('deadline exceeded after 5s', $e->getMessage()); + // Inherited from FormBindingInfraException — no override. + $this->assertSame('temporary_error', $e->reasonCode()); + } + + public function test_timeout_extends_infra(): void + { + $this->assertTrue(is_subclass_of( + FormBindingApplicatorTimeoutException::class, + FormBindingInfraException::class, + )); + } + + public function test_all_concrete_subclasses_extend_base(): void + { + $concreteSubclasses = [ + FormBindingSchemaConfigException::class, + FormBindingInfraException::class, + FormBindingDataIntegrityException::class, + FormBindingApplicatorTimeoutException::class, + ]; + + foreach ($concreteSubclasses as $class) { + $this->assertTrue( + is_subclass_of($class, FormBindingApplicatorException::class), + "Class {$class} must extend FormBindingApplicatorException", + ); + } + } + + public function test_constructor_accepts_previous_throwable(): void + { + $cause = new RuntimeException('original'); + $e = new FormBindingInfraException( + submissionId: '01HX', + message: 'wrapper', + previous: $cause, + ); + + $this->assertSame($cause, $e->getPrevious()); + } + + public function test_identity_match_invariant_violation_is_not_in_hierarchy(): void + { + $this->assertFalse(is_subclass_of( + IdentityMatchInvariantViolation::class, + FormBindingApplicatorException::class, + )); + } + + public function test_identity_match_invariant_violation_is_domain_exception(): void + { + $this->assertTrue(is_subclass_of( + IdentityMatchInvariantViolation::class, + DomainException::class, + )); + } +} diff --git a/api/tests/Unit/FormBuilder/Bindings/FormBindingExceptionClassifierTest.php b/api/tests/Unit/FormBuilder/Bindings/FormBindingExceptionClassifierTest.php new file mode 100644 index 00000000..d4b98a5c --- /dev/null +++ b/api/tests/Unit/FormBuilder/Bindings/FormBindingExceptionClassifierTest.php @@ -0,0 +1,76 @@ +assertSame('schema_config_error', $code); + } + + public function test_classifies_infra_exception(): void + { + $code = FormBindingExceptionClassifier::classify( + new FormBindingInfraException(submissionId: '01HX', message: 'x'), + ); + $this->assertSame('temporary_error', $code); + } + + public function test_classifies_timeout_as_temporary_error(): void + { + // Subclass dispatch — Timeout extends Infra, inherits reasonCode. + $code = FormBindingExceptionClassifier::classify( + new FormBindingApplicatorTimeoutException(submissionId: '01HX', message: 'deadline'), + ); + $this->assertSame('temporary_error', $code); + } + + public function test_classifies_data_integrity_exception(): void + { + $code = FormBindingExceptionClassifier::classify( + new FormBindingDataIntegrityException(submissionId: '01HX', message: 'fk'), + ); + $this->assertSame('data_integrity_error', $code); + } + + public function test_classifies_arbitrary_runtime_exception_as_unknown(): void + { + $code = FormBindingExceptionClassifier::classify(new RuntimeException('boom')); + $this->assertSame('unknown_error', $code); + } + + public function test_classifies_identity_match_invariant_violation_as_unknown(): void + { + // IdentityMatchInvariantViolation is intentionally NOT in the + // FormBindingApplicatorException hierarchy (per RFC-WS-6 §Q2 — the + // listener that throws it runs outside the binding-applicator + // pipeline). It falls through to 'unknown_error' here, which is + // the right response-shape because users cannot meaningfully act + // on it; admins triage via GlitchTip. + $code = FormBindingExceptionClassifier::classify(new IdentityMatchInvariantViolation('x')); + $this->assertSame('unknown_error', $code); + } +}