diff --git a/api/app/Enums/FormBuilder/FormFieldBindingMergeStrategy.php b/api/app/Enums/FormBuilder/FormFieldBindingMergeStrategy.php index f66c0708..8df5558d 100644 --- a/api/app/Enums/FormBuilder/FormFieldBindingMergeStrategy.php +++ b/api/app/Enums/FormBuilder/FormFieldBindingMergeStrategy.php @@ -37,4 +37,31 @@ enum FormFieldBindingMergeStrategy: string { return $this !== self::Append; } + + /** + * Whether this strategy is structurally valid against the given target + * type. + * + * Per RFC-WS-6 §V1 + ARCH-BINDINGS §4.2 (strategy x target-type validity matrix). + * + * | SCALAR | COLLECTION | RELATION | + * Overwrite | valid | valid* | valid | + * Append | INVALID| valid | INVALID | + * Replace | valid | valid | valid | + * FirstWriteWins | valid | valid | valid | + * + * * unusual but valid (overwrites entire collection) + * + * The PublishGuard AppendStrategyRequiresCollectionTarget uses this + * method to validate at publish time. Append on scalars is rejected + * because it requires a fingerprint mechanism for retry-idempotency + * that would embed implementation detail in domain data. + */ + public function validForTargetType(BindingTargetType $type): bool + { + return match ($this) { + self::Append => $type === BindingTargetType::COLLECTION, + default => true, + }; + } } diff --git a/api/app/Events/FormBuilder/FormSubmissionIdentityMatchResolved.php b/api/app/Events/FormBuilder/FormSubmissionIdentityMatchResolved.php new file mode 100644 index 00000000..a3da5719 --- /dev/null +++ b/api/app/Events/FormBuilder/FormSubmissionIdentityMatchResolved.php @@ -0,0 +1,58 @@ + + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel("submission.{$this->submissionId}"), + ]; + } + + public function broadcastAs(): string + { + return 'identity-match.resolved'; + } +} diff --git a/api/app/Exceptions/FormBuilder/FormBindingApplicatorException.php b/api/app/Exceptions/FormBuilder/FormBindingApplicatorException.php index 8cfd9f42..ebf1f3fb 100644 --- a/api/app/Exceptions/FormBuilder/FormBindingApplicatorException.php +++ b/api/app/Exceptions/FormBuilder/FormBindingApplicatorException.php @@ -5,19 +5,48 @@ declare(strict_types=1); namespace App\Exceptions\FormBuilder; use RuntimeException; +use Throwable; /** - * RFC-WS-6 §3 (Q3) — catastrophic applicator failure that bubbles to - * the caller. Per-binding failures are captured in BindingPassResult, - * not thrown. + * Base for all FormBindingApplicator-pipeline exceptions. + * + * Subclasses provide a `reasonCode()` that maps to: + * - failure_response_code on form_submissions (response-shape driver) + * - HTTP status code (422 / 503 / 500) + * - user-facing copy class (rendered by frontend) + * + * Per RFC-WS-6 §Q3 v1.3 addition 2. + * + * Concrete subclasses: + * - FormBindingSchemaConfigException — schema misconfiguration (422, schema_config_error) + * - FormBindingInfraException — infra issue, retryable (503, temporary_error) + * - FormBindingApplicatorTimeoutException — deadline-wrapper exceeded (extends Infra) + * - FormBindingDataIntegrityException — data shape violation (422, data_integrity_error) + * + * The classifier (FormBindingExceptionClassifier) maps unknown Throwables + * to 'unknown_error' — that is the fallback for anything not in this + * hierarchy. + * + * `submissionId` is preserved as a public readonly property so D2's + * outer-transaction handler can structurally read it when writing the + * `form_submission_action_failures.context` JSON, instead of regex-parsing + * the message string. */ -final class FormBindingApplicatorException extends RuntimeException +abstract class FormBindingApplicatorException extends RuntimeException { public function __construct( - public readonly string $reasonCode, public readonly string $submissionId, - ?string $message = null, + string $message, + ?Throwable $previous = null, ) { - parent::__construct($message ?? "FormBindingApplicator failed: {$reasonCode} (submission {$submissionId})"); + parent::__construct($message, 0, $previous); } + + /** + * Response-shape classification token. One of: + * - 'schema_config_error' + * - 'temporary_error' + * - 'data_integrity_error' + */ + abstract public function reasonCode(): string; } diff --git a/api/app/Exceptions/FormBuilder/FormBindingApplicatorTimeoutException.php b/api/app/Exceptions/FormBuilder/FormBindingApplicatorTimeoutException.php new file mode 100644 index 00000000..79847178 --- /dev/null +++ b/api/app/Exceptions/FormBuilder/FormBindingApplicatorTimeoutException.php @@ -0,0 +1,26 @@ +id, - 'FormBindingApplicator must be invoked inside DB::transaction', + throw new FormBindingInfraException( + submissionId: (string) $submission->id, + message: 'FormBindingApplicator must be invoked inside DB::transaction', ); } /** @var \App\Models\FormBuilder\FormSchema|null $schema */ $schema = $submission->schema; if ($schema === null) { - throw new FormBindingApplicatorException( - 'no_schema', - (string) $submission->id, + throw new FormBindingSchemaConfigException( + submissionId: (string) $submission->id, + message: "schema null for submission {$submission->id}", ); } $purposeValue = $schema->purpose->value; if (! $this->purposeRegistry->has($purposeValue)) { - throw new FormBindingApplicatorException( - 'unknown_purpose', - (string) $submission->id, - "purpose '{$purposeValue}' not registered", + throw new FormBindingSchemaConfigException( + submissionId: (string) $submission->id, + message: "purpose '{$purposeValue}' not registered", ); } @@ -168,6 +168,7 @@ class FormBindingApplicator // Per-strategy matrix. RFC §3 Q7. if ($newValue === null) { $behaviour = $strategy->nullWinnerBehaviour(); + return match ($behaviour) { 'write' => null, 'noop' => self::NO_OP, diff --git a/api/app/FormBuilder/Bindings/FormBindingExceptionClassifier.php b/api/app/FormBuilder/Bindings/FormBindingExceptionClassifier.php new file mode 100644 index 00000000..36d2f04f --- /dev/null +++ b/api/app/FormBuilder/Bindings/FormBindingExceptionClassifier.php @@ -0,0 +1,39 @@ +reasonCode(); + } + + return 'unknown_error'; + } +} diff --git a/api/app/Models/FormBuilder/FormSubmission.php b/api/app/Models/FormBuilder/FormSubmission.php index d871a23a..b9be4492 100644 --- a/api/app/Models/FormBuilder/FormSubmission.php +++ b/api/app/Models/FormBuilder/FormSubmission.php @@ -11,9 +11,9 @@ use App\Models\Event; use App\Models\Organisation; use App\Models\Scopes\OrganisationScope; use App\Models\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -32,7 +32,7 @@ final class FormSubmission extends Model protected static function booted(): void { - static::addGlobalScope(new OrganisationScope()); + self::addGlobalScope(new OrganisationScope); } /** @return array{column: string} */ @@ -72,6 +72,7 @@ final class FormSubmission extends Model 'first_interacted_at', 'idempotency_key', 'identity_match_status', + 'failure_response_code', ]; /** @var array */ @@ -80,6 +81,11 @@ final class FormSubmission extends Model 'review_status' => FormSubmissionReviewStatus::class, 'apply_status' => ApplyStatus::class, 'apply_completed_at' => 'datetime', + // Plain string (not enum). The exception subclass on + // form_submission_action_failures is the canonical source of + // classification truth; this column is a denormalised mirror for + // response-shape rendering. Per RFC-WS-6 §Q3 v1.3 addition 2. + 'failure_response_code' => 'string', 'schema_snapshot' => 'array', 'is_test' => 'bool', 'submitted_at' => 'datetime', diff --git a/api/database/factories/FormBuilder/FormSubmissionFactory.php b/api/database/factories/FormBuilder/FormSubmissionFactory.php index 4bc4b264..d4ca460a 100644 --- a/api/database/factories/FormBuilder/FormSubmissionFactory.php +++ b/api/database/factories/FormBuilder/FormSubmissionFactory.php @@ -64,4 +64,23 @@ final class FormSubmissionFactory extends Factory 'organisation_id' => $event->organisation_id, ]); } + + /** + * Set failure_response_code. Typically used in tests asserting the + * downstream rendering behaviour for failed submissions. + * + * Valid values per RFC-WS-6 §Q3 v1.3 addition 2: + * - 'schema_config_error' + * - 'temporary_error' + * - 'data_integrity_error' + * - 'unknown_error' + * + * Note: writing this value via factory does NOT itself set apply_status + * to FAILED. Pair with an explicit apply_status attribute in tests + * that need a coherent failed-submission fixture. + */ + public function withFailureResponseCode(string $code): static + { + return $this->state(fn () => ['failure_response_code' => $code]); + } } diff --git a/api/database/migrations/2026_05_08_000001_add_failure_response_code_to_form_submissions.php b/api/database/migrations/2026_05_08_000001_add_failure_response_code_to_form_submissions.php new file mode 100644 index 00000000..239f3480 --- /dev/null +++ b/api/database/migrations/2026_05_08_000001_add_failure_response_code_to_form_submissions.php @@ -0,0 +1,47 @@ +string('failure_response_code', 40) + ->nullable() + ->after('apply_status'); + + $table->index('failure_response_code', 'fs_failure_response_code_idx'); + }); + } + + public function down(): void + { + Schema::table('form_submissions', function (Blueprint $table): void { + $table->dropIndex('fs_failure_response_code_idx'); + $table->dropColumn('failure_response_code'); + }); + } +}; diff --git a/api/database/schema/mysql-schema.sql b/api/database/schema/mysql-schema.sql index 190c5959..8daf9e65 100644 --- a/api/database/schema/mysql-schema.sql +++ b/api/database/schema/mysql-schema.sql @@ -761,6 +761,7 @@ CREATE TABLE `form_submissions` ( `anonymised_at` timestamp NULL DEFAULT NULL, `identity_match_status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `apply_status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `failure_response_code` varchar(40) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `apply_completed_at` timestamp NULL DEFAULT NULL, `search_index` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, `created_at` timestamp NULL DEFAULT NULL, @@ -778,6 +779,7 @@ CREATE TABLE `form_submissions` ( KEY `fs_event_status_idx` (`event_id`,`status`), KEY `fs_schema_apply_status_idx` (`form_schema_id`,`apply_status`), KEY `fs_org_apply_status_idx` (`organisation_id`,`apply_status`), + KEY `fs_failure_response_code_idx` (`failure_response_code`), FULLTEXT KEY `fs_search_index_fulltext` (`search_index`), CONSTRAINT `form_submissions_event_id_foreign` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE SET NULL, CONSTRAINT `form_submissions_form_schema_id_foreign` FOREIGN KEY (`form_schema_id`) REFERENCES `form_schemas` (`id`) ON DELETE CASCADE, @@ -1773,3 +1775,4 @@ INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (123,'2026_04_28_10 INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (124,'2026_04_28_140000_add_kvk_number_to_companies_table',2); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (125,'2026_04_28_180000_create_form_submission_action_failure_retry_attempts_table',2); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (126,'2026_04_28_181000_add_exception_trace_to_form_submission_action_failures',2); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (127,'2026_05_08_000001_add_failure_response_code_to_form_submissions',3); 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/Bindings/FormBindingApplicatorIntegrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php index cb7aa846..3e70b83a 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormBindingApplicatorIntegrationTest.php @@ -65,11 +65,15 @@ final class FormBindingApplicatorIntegrationTest extends TestCase // RefreshDatabase wraps every PHPUnit test in a transaction; the // guard is exercised via the listener path (ApplyBindingsOnFormSubmit) // which opens its own transaction explicitly. Verify the guard - // exists in the source. + // exists in the source by checking for the throw of + // FormBindingInfraException (per RFC-WS-6 §Q3 v1.3 addition 2 — the + // 'no_transaction' developer-error maps onto temporary_error so the + // GlitchTip alert + retry-after workflow fires). $reflection = new \ReflectionClass(FormBindingApplicator::class); $source = file_get_contents($reflection->getFileName()); - $this->assertStringContainsString('no_transaction', $source); + $this->assertStringContainsString('FormBindingInfraException', $source); $this->assertStringContainsString('DB::transactionLevel()', $source); + $this->assertStringContainsString('must be invoked inside DB::transaction', $source); } private function applicator(): FormBindingApplicator @@ -151,7 +155,7 @@ final class FormBindingApplicatorIntegrationTest extends TestCase private function writeValue(string $submissionId, string $fieldId, mixed $value): void { - $row = new FormValue(); + $row = new FormValue; $row->form_submission_id = $submissionId; $row->form_field_id = $fieldId; $row->setAttribute('value', $value); diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index 97e06d67..f797018c 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -55,9 +55,10 @@ final class FormFieldBindingMigrationTest extends TestCase // create-conditional-logic-groups) + 5 WS-5b migrations // (drop-validation-cols, configs-backfill, create-configs, // validation-rules-backfill, create-validation-rules) + - // 2 WS-6 migrations (action-failures, apply-status) + - // 2 WS-5a migrations (drop-binding-cols, create-bindings) = 16. - $this->artisan('migrate:rollback', ['--step' => 21])->assertSuccessful(); + // WS-6 migrations (action-failures, apply-status, retry-attempts, + // exception-trace, failure-response-code [v1.3-delta D1]) + + // 2 WS-5a migrations (drop-binding-cols, create-bindings). + $this->artisan('migrate:rollback', ['--step' => 22])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding')); @@ -118,8 +119,9 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_rollback_reconstructs_json_and_drops_table(): void { - // Walk back the full WS-5d + WS-5c + WS-6 + WS-5b + WS-5a stack (16 migrations). - $this->artisan('migrate:rollback', ['--step' => 21])->assertSuccessful(); + // Walk back the full WS-5d + WS-5c + WS-6 (incl. v1.3-delta D1 + // failure_response_code) + WS-5b + WS-5a stack. + $this->artisan('migrate:rollback', ['--step' => 22])->assertSuccessful(); [$fieldAId] = $this->seedFieldsWithBindingJson(); [$libAId] = $this->seedLibraryWithBindingJson(); @@ -130,11 +132,12 @@ final class FormFieldBindingMigrationTest extends TestCase $this->assertSame(5, DB::table('form_field_bindings')->count()); // Step back over WS-5d (3 migrations) + WS-5c (4 migrations) + - // WS-6 (2 migrations) + WS-5b (5 migrations) in one go → restores - // the pre-WS-5b state (conditional-logic, validation-rules, configs - // and options tables gone, validation_rules + options JSON columns - // reappear on source tables; binding contract intact). - $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); + // WS-6 (action-failures, apply-status, retry-attempts, exception-trace, + // failure-response-code [v1.3-delta D1]) + WS-5b (5 migrations) in one + // go → restores the pre-WS-5b state (conditional-logic, validation-rules, + // configs and options tables gone, validation_rules + options JSON + // columns reappear on source tables; binding contract intact). + $this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_options')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions')); diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php index 044d1655..d6db383a 100644 --- a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php @@ -49,7 +49,7 @@ final class ConditionalLogicBackfillTest extends TestCase // create-options + WS-5c drop-cl-col + WS-5c backfill-cl // migrations to land in the conditional-logic JSON-era state with // no relational form_field_options table yet. - $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic')); $fieldId = $this->seedFieldWithJson([ @@ -170,7 +170,7 @@ final class ConditionalLogicBackfillTest extends TestCase ]); // Roll back only the backfill migration — writes the JSON back. - $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); $reconstructed = DB::table('form_fields') ->where('id', $fieldId) @@ -203,7 +203,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_top_level_key_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); $this->seedFieldWithJson([ 'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]], @@ -216,7 +216,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_comparison_operator_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); $this->seedFieldWithJson([ 'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]], diff --git a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php index a8b35ede..0490105a 100644 --- a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php +++ b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php @@ -30,7 +30,7 @@ final class FormFieldConfigBackfillAndDropTest extends TestCase // Roll back 4 WS-5c migrations + 2 WS-6 migrations + 5 WS-5b // migrations = 11, to get the pre-WS-5b state where the JSON column // still exists on form_fields / form_field_library. - $this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $fieldId = $this->seedField([ 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/Options/FormFieldOptionsBackfillTest.php b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php index 4fc7b045..173c685c 100644 --- a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php +++ b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php @@ -47,7 +47,7 @@ final class FormFieldOptionsBackfillTest extends TestCase // Roll back only the backfill migration (latest WS-5d step). // Leaves the form_field_options table in place, JSON columns // present on the source tables, and snapshots in pre-WS-5d shape. - $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->assertTrue(Schema::hasTable('form_field_options')); $this->assertTrue(Schema::hasColumn('form_fields', 'options')); @@ -136,7 +136,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_rollback_reconstructs_json_columns_and_snapshots(): void { - $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); [$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson(); $submissionId = $this->seedSubmissionWithSnapshot($selectId); @@ -149,7 +149,7 @@ final class FormFieldOptionsBackfillTest extends TestCase // Step back over only the backfill migration → JSON columns repopulate // and snapshots revert to flat-string-array shape. - $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->assertSame(0, DB::table('form_field_options')->count()); $select = DB::table('form_fields')->where('id', $selectId)->first(); @@ -168,7 +168,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_present_on_non_option_field_type(): void { - $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->seedFieldWithOptions('TAG_PICKER', ['Veiligheid', 'Horeca']); $this->expectException(\RuntimeException::class); @@ -178,7 +178,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_contains_non_string_entry(): void { - $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode([ ['label' => 'A'], @@ -192,7 +192,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_is_object_shape(): void { - $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode([ 'XS' => 'Extra small', @@ -206,7 +206,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_translations_length_mismatch(): void { - $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S', 'M']), json_encode([ 'de' => ['options' => ['Klein', 'Mittel']], // 2 vs 3 ])); @@ -218,7 +218,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_non_string_translation(): void { - $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S']), json_encode([ 'de' => ['options' => ['Klein', 42]], ])); @@ -230,7 +230,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_oversized_translation(): void { - $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS']), json_encode([ 'de' => ['options' => [str_repeat('x', 256)]], ])); @@ -242,7 +242,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_snapshot_options_present_on_non_option_field_type(): void { - $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); $this->seedTemplateWithSnapshotRaw([ 'fields' => [[ 'id' => (string) Str::ulid(), 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/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php index 295c5455..29b86f6a 100644 --- a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php @@ -53,7 +53,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // validation-rules-backfill + create-validation-rules) = 14. // Brings us to the pre-WS-5b state: validation_rules JSON column // present, no relational tables for WS-5b/c/d. - $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); @@ -114,7 +114,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TAG_PICKER', @@ -138,7 +138,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -165,7 +165,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -182,7 +182,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'BOOLEAN', @@ -201,7 +201,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // full-back-then-full-forward cycle — rolling back all WS-5b // migrations restores the pre-WS-5b state (columns present on // source tables; validation rules relational table gone). - $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful(); [$numberId] = $this->seedFields(); $this->artisan('migrate')->assertSuccessful(); @@ -216,7 +216,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // Roll back WS-5b fully → column reappears and carries canonical JSON // reconstructed from the relational rows. - $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $field = DB::table('form_fields')->where('id', $numberId)->first(); 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); + } +}