diff --git a/api/app/Services/FormBuilder/FormFieldBindingService.php b/api/app/Services/FormBuilder/FormFieldBindingService.php index 063643ad..f61ff87a 100644 --- a/api/app/Services/FormBuilder/FormFieldBindingService.php +++ b/api/app/Services/FormBuilder/FormFieldBindingService.php @@ -192,26 +192,22 @@ final class FormFieldBindingService } /** - * Build snapshot fragments for both the legacy `binding` (singular) - * key and the new WS-6 `bindings` (plural) key in one pass over the - * collection. Single helper so the FormSubmissionService snapshot - * loop accesses `$field->bindings` only once. + * Build the snapshot fragment for the WS-6 `bindings` (plural) key. + * Session 2.5 dropped the legacy `binding` (singular) — see RFC v1.1 + * + WS-6 session 2 deviation #9. With no production data and dev + * seeders re-running every cycle, dual-key state had no upside. * * @param iterable $bindings - * @return array{binding: array|null, bindings: list>} + * @return array{bindings: list>} */ public function snapshotShapesFor(iterable $bindings): array { - $first = null; $all = []; foreach ($bindings as $binding) { - if ($first === null) { - $first = $this->toJsonShape($binding); - } $all[] = $this->toApplicatorShape($binding); } - return ['binding' => $first, 'bindings' => $all]; + return ['bindings' => $all]; } private function ownerTypeFor(FormField|FormFieldLibrary $owner): string diff --git a/api/database/seeders/FormBuilderDevSeeder.php b/api/database/seeders/FormBuilderDevSeeder.php index 955d5e6e..48edac44 100644 --- a/api/database/seeders/FormBuilderDevSeeder.php +++ b/api/database/seeders/FormBuilderDevSeeder.php @@ -97,7 +97,7 @@ final class FormBuilderDevSeeder 'is_required' => $field['is_required'] ?? false, 'is_filterable' => $field['is_filterable'] ?? false, 'is_pii' => $field['is_pii'] ?? false, - 'binding' => null, // Pattern B — snapshot embeds null for form-owned fields. + 'bindings' => [], // Pattern B — snapshot embeds an empty array for form-owned fields. 'conditional_logic' => null, // snapshot shape: null for fields without conditional logic 'translations' => null, 'value_storage_hint' => $field['type']->recommendedValueStorageHint()->value, diff --git a/api/tests/Feature/FormBuilder/Bindings/SchemaSnapshotEmbedsBindingFromRelationalTableTest.php b/api/tests/Feature/FormBuilder/Bindings/SchemaSnapshotEmbedsBindingFromRelationalTableTest.php index d19cb76c..b25e37f2 100644 --- a/api/tests/Feature/FormBuilder/Bindings/SchemaSnapshotEmbedsBindingFromRelationalTableTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/SchemaSnapshotEmbedsBindingFromRelationalTableTest.php @@ -19,17 +19,17 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; /** - * Asserts the `schema_snapshot` JSON keeps its ARCH §4.6.1 / §6.3 shape - * after WS-5a: the snapshot writer now serialises bindings from the - * relational table via FormFieldBindingService::toJsonShape, but the - * per-field JSON embedded in the snapshot is byte-for-byte the pre-WS-5a - * shape for entity-owned and mirrored patterns. + * Asserts the `schema_snapshot` JSON `bindings` (plural) key carries the + * RFC-WS-6 §3 Q6 applicator shape via + * FormFieldBindingService::toApplicatorShape. Pre-WS-6 used a legacy + * `binding` (singular) key with a leaner shape; that key was dropped + * in WS-6 session 2.5 (no production data; dual-key state had no upside). */ final class SchemaSnapshotEmbedsBindingFromRelationalTableTest extends TestCase { use RefreshDatabase; - public function test_snapshot_embeds_entity_owned_binding_in_arch_shape(): void + public function test_snapshot_embeds_entity_owned_binding_in_applicator_shape(): void { $schema = $this->schemaWithSnapshot(); @@ -39,7 +39,7 @@ final class SchemaSnapshotEmbedsBindingFromRelationalTableTest extends TestCase 'slug' => 'email', 'label' => 'E-mail', ]); - FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create(); + $binding = FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create(); $submission = $this->submitFor($schema); $snapshot = $submission->fresh()->schema_snapshot; @@ -47,11 +47,16 @@ final class SchemaSnapshotEmbedsBindingFromRelationalTableTest extends TestCase $this->assertIsArray($snapshot); $embedded = collect($snapshot['fields'])->firstWhere('id', $field->id); $this->assertNotNull($embedded); - $this->assertSame([ - 'mode' => 'entity_owned', - 'entity' => 'person', - 'column' => 'email', - ], $embedded['binding']); + $this->assertArrayNotHasKey('binding', $embedded); + $this->assertCount(1, $embedded['bindings']); + $shape = $embedded['bindings'][0]; + $this->assertSame((string) $binding->id, $shape['id']); + $this->assertSame('entity_owned', $shape['mode']); + $this->assertSame('person', $shape['entity']); + $this->assertSame('email', $shape['column']); + $this->assertSame('overwrite', $shape['merge_strategy']); + $this->assertSame(50, $shape['trust_level']); + $this->assertFalse($shape['is_identity_key']); } public function test_snapshot_embeds_mirrored_binding_with_sync_direction(): void @@ -70,15 +75,15 @@ final class SchemaSnapshotEmbedsBindingFromRelationalTableTest extends TestCase $snapshot = $submission->fresh()->schema_snapshot; $embedded = collect($snapshot['fields'])->firstWhere('id', $field->id); - $this->assertSame([ - 'mode' => 'mirrored', - 'entity' => 'user_profile', - 'column' => 'emergency_contact_name', - 'sync_direction' => 'write_on_submit', - ], $embedded['binding']); + $this->assertCount(1, $embedded['bindings']); + $shape = $embedded['bindings'][0]; + $this->assertSame('mirrored', $shape['mode']); + $this->assertSame('user_profile', $shape['entity']); + $this->assertSame('emergency_contact_name', $shape['column']); + $this->assertSame('write_on_submit', $shape['sync_direction']); } - public function test_snapshot_embeds_null_binding_for_form_owned_field(): void + public function test_snapshot_embeds_empty_bindings_for_form_owned_field(): void { $schema = $this->schemaWithSnapshot(); @@ -93,7 +98,8 @@ final class SchemaSnapshotEmbedsBindingFromRelationalTableTest extends TestCase $snapshot = $submission->fresh()->schema_snapshot; $embedded = collect($snapshot['fields'])->firstWhere('id', $field->id); - $this->assertNull($embedded['binding']); + $this->assertSame([], $embedded['bindings']); + $this->assertArrayNotHasKey('binding', $embedded); } private function schemaWithSnapshot(): FormSchema diff --git a/api/tests/Feature/FormBuilder/Schema/SnapshotOnlyContainsBindingsKeyTest.php b/api/tests/Feature/FormBuilder/Schema/SnapshotOnlyContainsBindingsKeyTest.php new file mode 100644 index 00000000..8942735c --- /dev/null +++ b/api/tests/Feature/FormBuilder/Schema/SnapshotOnlyContainsBindingsKeyTest.php @@ -0,0 +1,71 @@ +create(); + $schema = FormSchema::factory()->create([ + 'organisation_id' => $organisation->id, + 'purpose' => FormPurpose::USER_PROFILE->value, + 'snapshot_mode' => FormSchemaSnapshotMode::ALWAYS->value, + ]); + + // Two fields: one with binding, one without (Pattern B). + $boundField = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::EMAIL->value, + 'slug' => 'email', + ]); + FormFieldBinding::factory()->forField($boundField)->entityOwned('user', 'email')->create(); + + FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'note', + ]); + + $user = User::factory()->create(); + $service = $this->app->make(FormSubmissionService::class); + $draft = $service->createDraft($schema, $user, $user); + /** @var FormSubmission $submitted */ + $submitted = $service->submit($draft, $user); + + $snapshot = $submitted->fresh()->schema_snapshot; + $this->assertIsArray($snapshot); + + foreach ($snapshot['fields'] as $entry) { + $this->assertArrayHasKey('bindings', $entry); + $this->assertIsArray($entry['bindings']); + $this->assertArrayNotHasKey( + 'binding', + $entry, + 'Snapshot field entries must NOT contain the legacy `binding` (singular) key.', + ); + } + } +}