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')); [$fieldAId, $fieldCId, $fieldDId] = $this->seedFieldsWithBindingJson(); [$libAId, $libCId] = $this->seedLibraryWithBindingJson(); $this->artisan('migrate')->assertSuccessful(); $this->assertTrue(Schema::hasTable('form_field_bindings')); $this->assertFalse(Schema::hasColumn('form_fields', 'binding')); $this->assertFalse(Schema::hasColumn('form_field_library', 'default_binding')); $rows = DB::table('form_field_bindings')->get(); $this->assertCount(5, $rows, 'Expected 3 field + 2 library rows'); $fieldRowA = DB::table('form_field_bindings') ->where('owner_type', 'form_field') ->where('owner_id', $fieldAId) ->first(); $this->assertSame('person', $fieldRowA->target_entity); $this->assertSame('email', $fieldRowA->target_attribute); $this->assertSame('entity_owned', $fieldRowA->mode); $this->assertNull($fieldRowA->sync_direction); $this->assertSame('overwrite', $fieldRowA->merge_strategy); $this->assertSame(50, (int) $fieldRowA->trust_level); $this->assertSame(0, (int) $fieldRowA->is_identity_key); $fieldRowC = DB::table('form_field_bindings') ->where('owner_type', 'form_field') ->where('owner_id', $fieldCId) ->first(); $this->assertSame('mirrored', $fieldRowC->mode); $this->assertSame('write_on_submit', $fieldRowC->sync_direction); $this->assertSame('user_profile', $fieldRowC->target_entity); $this->assertSame('emergency_contact_name', $fieldRowC->target_attribute); $fieldRowD = DB::table('form_field_bindings') ->where('owner_type', 'form_field') ->where('owner_id', $fieldDId) ->first(); $this->assertSame('entity_owned', $fieldRowD->mode); $libRowA = DB::table('form_field_bindings') ->where('owner_type', 'form_field_library') ->where('owner_id', $libAId) ->first(); $this->assertSame('person', $libRowA->target_entity); $this->assertSame('first_name', $libRowA->target_attribute); $this->assertSame('entity_owned', $libRowA->mode); $libRowC = DB::table('form_field_bindings') ->where('owner_type', 'form_field_library') ->where('owner_id', $libCId) ->first(); $this->assertSame('mirrored', $libRowC->mode); } public function test_rollback_reconstructs_json_and_drops_table(): void { // 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(); $this->artisan('migrate')->assertSuccessful(); // Fully-forward state: binding columns gone, rows in form_field_bindings. $this->assertFalse(Schema::hasColumn('form_fields', 'binding')); $this->assertSame(5, DB::table('form_field_bindings')->count()); // Step back over WS-5d (3 migrations) + WS-5c (4 migrations) + // 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')); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertFalse(Schema::hasTable('form_field_configs')); $this->assertTrue(Schema::hasTable('form_field_bindings')); // Step back over drop_binding_json_columns → columns reappear empty. $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertNull(DB::table('form_fields')->where('id', $fieldAId)->value('binding')); // Step back over create_form_field_bindings → JSON reconstructed. $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); // RFC-WS-6 session 2.7: rollback writes JSON directly from // migration code (not the canonicalizing service). Compare on // canonical form so the assertion is engine-agnostic. $field = DB::table('form_fields')->where('id', $fieldAId)->first(); $this->assertNotNull($field->binding); $this->assertSame( JsonCanonicalizer::encode([ 'mode' => 'entity_owned', 'entity' => 'person', 'column' => 'email', ]), JsonCanonicalizer::encode(json_decode((string) $field->binding, true)), ); $lib = DB::table('form_field_library')->where('id', $libAId)->first(); $this->assertNotNull($lib->default_binding); $this->assertSame( JsonCanonicalizer::encode([ 'mode' => 'entity_owned', 'entity' => 'person', 'column' => 'first_name', ]), JsonCanonicalizer::encode(json_decode((string) $lib->default_binding, true)), ); } /** @return array{0:string,1:string,2:string} */ private function seedFieldsWithBindingJson(): array { $org = Organisation::factory()->create(); $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); $fieldA = (string) Str::ulid(); $fieldC = (string) Str::ulid(); $fieldD = (string) Str::ulid(); DB::table('form_fields')->insert([ [ 'id' => $fieldA, 'form_schema_id' => $schema->id, 'field_type' => 'EMAIL', 'slug' => 'email', 'label' => 'E-mail', 'binding' => json_encode(['mode' => 'entity_owned', 'entity' => 'person', 'column' => 'email']), 'value_storage_hint' => 'indexed', 'sort_order' => 0, 'created_at' => now(), 'updated_at' => now(), ], [ 'id' => $fieldC, 'form_schema_id' => $schema->id, 'field_type' => 'TEXT', 'slug' => 'noodcontact', 'label' => 'Noodcontact', 'binding' => json_encode([ 'mode' => 'mirrored', 'entity' => 'user_profile', 'column' => 'emergency_contact_name', 'sync_direction' => 'write_on_submit', ]), 'value_storage_hint' => 'indexed', 'sort_order' => 1, 'created_at' => now(), 'updated_at' => now(), ], [ 'id' => $fieldD, 'form_schema_id' => $schema->id, 'field_type' => 'TEXT', 'slug' => 'voornaam', 'label' => 'Voornaam', 'binding' => json_encode(['mode' => 'entity_owned', 'entity' => 'person', 'column' => 'first_name']), 'value_storage_hint' => 'indexed', 'sort_order' => 2, 'created_at' => now(), 'updated_at' => now(), ], ]); return [$fieldA, $fieldC, $fieldD]; } /** @return array{0:string,1:string} */ private function seedLibraryWithBindingJson(): array { $org = Organisation::factory()->create(); $libA = (string) Str::ulid(); $libC = (string) Str::ulid(); DB::table('form_field_library')->insert([ [ 'id' => $libA, 'organisation_id' => $org->id, 'name' => 'Voornaam bibliotheek', 'slug' => 'voornaam-lib', 'field_type' => 'TEXT', 'label' => 'Voornaam', 'default_binding' => json_encode(['mode' => 'entity_owned', 'entity' => 'person', 'column' => 'first_name']), 'default_is_required' => false, 'default_is_filterable' => false, 'usage_count' => 0, 'is_system' => false, 'is_active' => true, 'created_at' => now(), 'updated_at' => now(), ], [ 'id' => $libC, 'organisation_id' => $org->id, 'name' => 'Noodcontact bibliotheek', 'slug' => 'noodcontact-lib', 'field_type' => 'TEXT', 'label' => 'Noodcontact', 'default_binding' => json_encode([ 'mode' => 'mirrored', 'entity' => 'user_profile', 'column' => 'emergency_contact_phone', 'sync_direction' => 'write_on_submit', ]), 'default_is_required' => false, 'default_is_filterable' => false, 'usage_count' => 0, 'is_system' => false, 'is_active' => true, 'created_at' => now(), 'updated_at' => now(), ], ]); return [$libA, $libC]; } }