artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); [$numberId, $textId, $dateId, $sectionPriorityId] = $this->seedFields(); [$libAId] = $this->seedLibrary(); $this->artisan('migrate')->assertSuccessful(); $this->assertTrue(Schema::hasTable('form_field_validation_rules')); $numberRules = DB::table('form_field_validation_rules') ->where('owner_type', 'form_field') ->where('owner_id', $numberId) ->get()->keyBy('rule_type'); $this->assertTrue($numberRules->has('min_value')); $this->assertTrue($numberRules->has('max_value')); $this->assertSame(16, json_decode((string) $numberRules['min_value']->parameters, true)['value']); $this->assertSame(99, json_decode((string) $numberRules['max_value']->parameters, true)['value']); $textRules = DB::table('form_field_validation_rules') ->where('owner_type', 'form_field') ->where('owner_id', $textId) ->get()->keyBy('rule_type'); $this->assertTrue($textRules->has('min_length')); $this->assertTrue($textRules->has('max_length')); $this->assertSame(5, json_decode((string) $textRules['min_length']->parameters, true)['value']); $this->assertSame(80, json_decode((string) $textRules['max_length']->parameters, true)['value']); $dateRules = DB::table('form_field_validation_rules') ->where('owner_type', 'form_field') ->where('owner_id', $dateId) ->get()->keyBy('rule_type'); $this->assertTrue($dateRules->has('date_min')); $this->assertSame('2026-01-01', json_decode((string) $dateRules['date_min']->parameters, true)['date']); $sectionRules = DB::table('form_field_validation_rules') ->where('owner_type', 'form_field') ->where('owner_id', $sectionPriorityId) ->get()->keyBy('rule_type'); $this->assertTrue($sectionRules->has('max_selected'), 'max_priorities should canonicalise to max_selected'); $this->assertSame(3, json_decode((string) $sectionRules['max_selected']->parameters, true)['value']); $this->assertFalse($sectionRules->has('max_priorities'), 'legacy key must not survive'); $libRules = DB::table('form_field_validation_rules') ->where('owner_type', 'form_field_library') ->where('owner_id', $libAId) ->get()->keyBy('rule_type'); $this->assertTrue($libRules->has('regex')); $this->assertSame( '/^[A-Z]{3}$/', json_decode((string) $libRules['regex']->parameters, true)['pattern'], ); } public function test_tag_categories_and_storage_disk_skipped_for_commit_5(): void { // Roll back all WS-5b migrations to reach the pre-WS-5b state // (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' => 11])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TAG_PICKER', 'validation_rules' => [ 'tag_categories' => ['Veiligheid'], 'storage_disk' => 'local', ], ]); $this->artisan('migrate')->assertSuccessful(); $rows = DB::table('form_field_validation_rules') ->where('owner_id', $fieldId) ->get(); $this->assertCount(0, $rows, 'configs keys must not land in the validation-rules table'); } public function test_required_and_unique_skipped_with_warn(): void { // Roll back all WS-5b migrations to reach the pre-WS-5b state // (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' => 11])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TEXT', 'validation_rules' => [ 'required' => true, 'unique' => true, 'min' => 2, ], ]); $this->artisan('migrate')->assertSuccessful(); $rows = DB::table('form_field_validation_rules') ->where('owner_id', $fieldId) ->pluck('rule_type') ->all(); sort($rows); $this->assertSame(['min_length'], $rows, 'only min_length should land (required/unique skipped)'); } public function test_unknown_top_level_key_fails_migration(): void { // Roll back all WS-5b migrations to reach the pre-WS-5b state // (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' => 11])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'TEXT', 'validation_rules' => ['nonsense_key' => 42], ]); $this->expectException(\RuntimeException::class); $this->artisan('migrate'); } public function test_unmapped_field_type_for_min_max_fails_migration(): void { // Roll back all WS-5b migrations to reach the pre-WS-5b state // (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' => 11])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'BOOLEAN', 'validation_rules' => ['min' => 1], ]); $this->expectException(\RuntimeException::class); $this->artisan('migrate'); } public function test_full_wsb_rollback_reconstructs_source_state(): void { // Architect contract: "the forward+back pair is safe when run as a // unit; a partial 'rollback this migration but not its create-table // sibling' state is not supported." This test exercises the // 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' => 11])->assertSuccessful(); [$numberId] = $this->seedFields(); $this->artisan('migrate')->assertSuccessful(); // Post-migration: rows exist, column dropped. $this->assertSame( 2, DB::table('form_field_validation_rules') ->where('owner_id', $numberId) ->count(), ); $this->assertFalse(Schema::hasColumn('form_fields', 'validation_rules')); // Roll back WS-5b fully → column reappears and carries canonical JSON // reconstructed from the relational rows. $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $field = DB::table('form_fields')->where('id', $numberId)->first(); $decoded = json_decode((string) $field->validation_rules, true); $this->assertSame(16, $decoded['min_value']); $this->assertSame(99, $decoded['max_value']); } /** @return array{0:string,1:string,2:string,3:string} */ private function seedFields(): array { $org = Organisation::factory()->create(); $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); $number = (string) Str::ulid(); $text = (string) Str::ulid(); $date = (string) Str::ulid(); $section = (string) Str::ulid(); DB::table('form_fields')->insert([ $this->row($number, $schema->id, 'NUMBER', 'leeftijd', ['min' => 16, 'max' => 99]), $this->row($text, $schema->id, 'TEXT', 'postcode', ['min' => 5, 'max' => 80]), $this->row($date, $schema->id, 'DATE', 'startdatum', ['min' => '2026-01-01']), $this->row($section, $schema->id, 'SECTION_PRIORITY', 'sectie-voorkeur', ['max_priorities' => 3]), ]); return [$number, $text, $date, $section]; } /** @param array $validationRules */ private function row(string $id, string $schemaId, string $fieldType, string $slug, array $validationRules): array { return [ 'id' => $id, 'form_schema_id' => $schemaId, 'field_type' => $fieldType, 'slug' => $slug, 'label' => $slug, 'validation_rules' => json_encode($validationRules), 'value_storage_hint' => 'json', 'sort_order' => 0, 'created_at' => now(), 'updated_at' => now(), ]; } /** @return array{0:string} */ private function seedLibrary(): array { $org = Organisation::factory()->create(); $lib = (string) Str::ulid(); DB::table('form_field_library')->insert([ [ 'id' => $lib, 'organisation_id' => $org->id, 'name' => 'License plate bibliotheek', 'slug' => 'kenteken-lib', 'field_type' => 'TEXT', 'label' => 'Kenteken', 'validation_rules' => json_encode(['regex' => '/^[A-Z]{3}$/']), 'default_is_required' => false, 'default_is_filterable' => false, 'usage_count' => 0, 'is_system' => false, 'is_active' => true, 'created_at' => now(), 'updated_at' => now(), ], ]); return [$lib]; } /** @param array $attrs */ private function seedFieldWithJson(array $attrs): string { $org = Organisation::factory()->create(); $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); $id = (string) Str::ulid(); DB::table('form_fields')->insert([[ 'id' => $id, 'form_schema_id' => $schema->id, 'field_type' => $attrs['field_type'], 'slug' => 'f-'.Str::lower(Str::random(4)), 'label' => 'field', 'validation_rules' => json_encode($attrs['validation_rules']), 'value_storage_hint' => 'json', 'sort_order' => 0, 'created_at' => now(), 'updated_at' => now(), ]]); return $id; } }