artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->assertTrue(Schema::hasTable('form_field_options')); $this->assertTrue(Schema::hasColumn('form_fields', 'options')); [$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson(); $submissionId = $this->seedSubmissionWithSnapshot($selectId); $templateId = $this->seedTemplateWithSnapshot(); $this->artisan('migrate')->assertSuccessful(); // Forward state: form_field_options rows present. $selectOptions = DB::table('form_field_options') ->where('owner_type', 'form_field') ->where('owner_id', $selectId) ->orderBy('sort_order') ->get(); $this->assertCount(3, $selectOptions); $this->assertSame(['XS', 'S', 'M'], $selectOptions->pluck('value')->all()); $this->assertSame(['XS', 'S', 'M'], $selectOptions->pluck('label')->all()); // Translations from form_fields.translations.{locale}.options moved // onto each option row, indexed by sort_order. $small = $selectOptions->firstWhere('sort_order', 1); $this->assertSame(['de' => 'Klein'], json_decode((string) $small->translations, true)); $xs = $selectOptions->firstWhere('sort_order', 0); $this->assertSame(['de' => 'Größe XS'], json_decode((string) $xs->translations, true)); $multiOptions = DB::table('form_field_options') ->where('owner_type', 'form_field') ->where('owner_id', $multiId) ->orderBy('sort_order') ->get(); $this->assertCount(2, $multiOptions); $libraryOptions = DB::table('form_field_options') ->where('owner_type', 'form_field_library') ->where('owner_id', $libraryId) ->orderBy('sort_order') ->get(); $this->assertCount(2, $libraryOptions); $this->assertSame(['lib_a', 'lib_b'], $libraryOptions->pluck('value')->all()); // Per-locale options[] stripped from form_fields.translations. $remaining = DB::table('form_fields')->where('id', $selectId)->value('translations'); $bag = json_decode((string) $remaining, true); $this->assertIsArray($bag); foreach ($bag as $locale => $localeBag) { $this->assertArrayNotHasKey('options', $localeBag, "locale {$locale} retained options key"); } // Submission snapshot rewritten to rich shape. $submission = DB::table('form_submissions')->where('id', $submissionId)->first(); $snapshot = json_decode((string) $submission->schema_snapshot, true); $field = $snapshot['fields'][0]; // RFC-WS-6 session 2.7: this snapshot was rewritten by the // migration's forward() (raw DB writer, not via the canonicalizing // service). Compare on canonical form so the assertion is // engine-agnostic. $this->assertSame( JsonCanonicalizer::encode([ ['value' => 'XS', 'label' => 'XS', 'sort_order' => 0, 'translations' => ['de' => 'Größe XS']], ['value' => 'S', 'label' => 'S', 'sort_order' => 1, 'translations' => ['de' => 'Klein']], ['value' => 'M', 'label' => 'M', 'sort_order' => 2, 'translations' => ['de' => 'Mittel']], ]), JsonCanonicalizer::encode($field['options']), ); // Field-level translations bag has the {locale}.options key // stripped. if (is_array($field['translations'] ?? null)) { foreach ($field['translations'] as $locale => $localeBag) { $this->assertArrayNotHasKey('options', $localeBag); } } // Template snapshot rewritten the same way. $template = DB::table('form_templates')->where('id', $templateId)->first(); $tplSnap = json_decode((string) $template->schema_snapshot, true); // RFC-WS-6 session 2.7: snapshot rewritten by migration; compare // on canonical form to be engine-agnostic. $this->assertSame( JsonCanonicalizer::encode([ ['value' => 'A', 'label' => 'A', 'sort_order' => 0], ['value' => 'B', 'label' => 'B', 'sort_order' => 1], ]), JsonCanonicalizer::encode($tplSnap['fields'][0]['options']), ); } public function test_rollback_reconstructs_json_columns_and_snapshots(): void { $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); [$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson(); $submissionId = $this->seedSubmissionWithSnapshot($selectId); $this->artisan('migrate')->assertSuccessful(); $this->assertSame( 7, DB::table('form_field_options')->count(), '3 + 2 + 2 owner rows', ); // 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->assertSame(0, DB::table('form_field_options')->count()); $select = DB::table('form_fields')->where('id', $selectId)->first(); $this->assertSame(['XS', 'S', 'M'], json_decode((string) $select->options, true)); $bag = json_decode((string) $select->translations, true); $this->assertSame(['Größe XS', 'Klein', 'Mittel'], $bag['de']['options']); $library = DB::table('form_field_library')->where('id', $libraryId)->first(); $this->assertSame(['lib_a', 'lib_b'], json_decode((string) $library->options, true)); $submission = DB::table('form_submissions')->where('id', $submissionId)->first(); $snapshot = json_decode((string) $submission->schema_snapshot, true); $this->assertSame(['XS', 'S', 'M'], $snapshot['fields'][0]['options']); $this->assertSame(['Größe XS', 'Klein', 'Mittel'], $snapshot['fields'][0]['translations']['de']['options']); } public function test_fails_when_options_present_on_non_option_field_type(): void { $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedFieldWithOptions('TAG_PICKER', ['Veiligheid', 'Horeca']); $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches('/Stale options on form_fields.*type=TAG_PICKER/'); $this->artisan('migrate')->assertSuccessful(); } public function test_fails_when_options_contains_non_string_entry(): void { $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode([ ['label' => 'A'], ['label' => 'B'], ])); $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches('/Expected flat string array/'); $this->artisan('migrate')->assertSuccessful(); } public function test_fails_when_options_is_object_shape(): void { $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode([ 'XS' => 'Extra small', 'S' => 'Small', ])); $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches('/Expected flat string array/'); $this->artisan('migrate')->assertSuccessful(); } public function test_fails_on_translations_length_mismatch(): void { $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S', 'M']), json_encode([ 'de' => ['options' => ['Klein', 'Mittel']], // 2 vs 3 ])); $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches('/Translations length mismatch/'); $this->artisan('migrate')->assertSuccessful(); } public function test_fails_on_non_string_translation(): void { $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S']), json_encode([ 'de' => ['options' => ['Klein', 42]], ])); $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches('/Invalid translated label/'); $this->artisan('migrate')->assertSuccessful(); } public function test_fails_on_oversized_translation(): void { $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS']), json_encode([ 'de' => ['options' => [str_repeat('x', 256)]], ])); $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches('/Invalid translated label/'); $this->artisan('migrate')->assertSuccessful(); } public function test_fails_when_snapshot_options_present_on_non_option_field_type(): void { $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedTemplateWithSnapshotRaw([ 'fields' => [[ 'id' => (string) Str::ulid(), 'slug' => 'tags', 'field_type' => 'TAG_PICKER', 'label' => 'Tags', 'options' => ['Veiligheid'], ]], ]); $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches('/Snapshot.*field_type is TAG_PICKER/'); $this->artisan('migrate')->assertSuccessful(); } /** @return array{0:string,1:string,2:string} */ private function seedFieldsAndLibraryWithJson(): array { $org = Organisation::factory()->create(); $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); $selectId = (string) Str::ulid(); $multiId = (string) Str::ulid(); DB::table('form_fields')->insert([ 'id' => $selectId, 'form_schema_id' => $schema->id, 'field_type' => 'SELECT', 'slug' => 'shirtmaat', 'label' => 'Shirtmaat', 'options' => json_encode(['XS', 'S', 'M']), 'translations' => json_encode([ 'de' => ['label' => 'Größe', 'options' => ['Größe XS', 'Klein', 'Mittel']], 'en' => ['label' => 'Size'], ]), 'value_storage_hint' => 'string', 'sort_order' => 0, 'created_at' => now(), 'updated_at' => now(), ]); DB::table('form_fields')->insert([ 'id' => $multiId, 'form_schema_id' => $schema->id, 'field_type' => 'MULTISELECT', 'slug' => 'dieet', 'label' => 'Dieet', 'options' => json_encode(['Vegan', 'Halal']), 'translations' => null, 'value_storage_hint' => 'json', 'sort_order' => 1, 'created_at' => now(), 'updated_at' => now(), ]); $libraryId = (string) Str::ulid(); DB::table('form_field_library')->insert([ 'id' => $libraryId, 'organisation_id' => $org->id, 'name' => 'Lib Select', 'slug' => 'lib-select', 'field_type' => 'SELECT', 'label' => 'Library', 'options' => json_encode(['lib_a', 'lib_b']), 'default_is_required' => false, 'default_is_filterable' => false, 'usage_count' => 0, 'is_system' => false, 'is_active' => true, 'created_at' => now(), 'updated_at' => now(), ]); return [$selectId, $multiId, $libraryId]; } private function seedSubmissionWithSnapshot(string $fieldId): string { $org = Organisation::factory()->create(); $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); $submissionId = (string) Str::ulid(); DB::table('form_submissions')->insert([ 'id' => $submissionId, 'organisation_id' => $org->id, 'form_schema_id' => $schema->id, 'subject_type' => 'person', 'subject_id' => (string) Str::ulid(), 'status' => 'submitted', 'is_test' => false, 'submitted_in_locale' => 'nl', 'schema_snapshot' => json_encode([ 'fields' => [[ 'id' => $fieldId, 'slug' => 'shirtmaat', 'field_type' => 'SELECT', 'label' => 'Shirtmaat', 'options' => ['XS', 'S', 'M'], 'translations' => [ 'de' => ['label' => 'Größe', 'options' => ['Größe XS', 'Klein', 'Mittel']], ], ]], ]), 'created_at' => now(), 'updated_at' => now(), ]); return $submissionId; } private function seedTemplateWithSnapshot(): string { $org = Organisation::factory()->create(); $templateId = (string) Str::ulid(); DB::table('form_templates')->insert([ 'id' => $templateId, 'organisation_id' => $org->id, 'name' => 'Tpl', 'slug' => 'tpl', 'purpose' => 'event_registration', 'description' => null, 'schema_snapshot' => json_encode([ 'fields' => [[ 'id' => (string) Str::ulid(), 'slug' => 'choice', 'field_type' => 'RADIO', 'label' => 'Choice', 'options' => ['A', 'B'], ]], ]), 'is_active' => true, 'is_system' => false, 'created_at' => now(), 'updated_at' => now(), ]); return $templateId; } private function seedFieldWithOptions(string $fieldType, array $options): string { return $this->seedFieldWithOptionsRaw($fieldType, json_encode($options)); } private function seedFieldWithOptionsRaw(string $fieldType, string $optionsJson, ?string $translationsJson = null): 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' => $fieldType, 'slug' => 'fld-'.Str::lower(Str::random(4)), 'label' => 'Test', 'options' => $optionsJson, 'translations' => $translationsJson, 'value_storage_hint' => 'string', 'sort_order' => 0, 'created_at' => now(), 'updated_at' => now(), ]); return $id; } private function seedTemplateWithSnapshotRaw(array $snapshot): string { $org = Organisation::factory()->create(); $id = (string) Str::ulid(); DB::table('form_templates')->insert([ 'id' => $id, 'organisation_id' => $org->id, 'name' => 'Tpl', 'slug' => 'tpl-'.Str::lower(Str::random(4)), 'purpose' => 'event_registration', 'description' => null, 'schema_snapshot' => json_encode($snapshot), 'is_active' => true, 'is_system' => false, 'created_at' => now(), 'updated_at' => now(), ]); return $id; } }