Test infrastructure now uses the same MySQL 8.0 engine as local dev
and production. SQLite is no longer used anywhere in the project.
Eliminates the SQLite "rebuild on FK add" quirk that forced session 2.5
to omit a foreign key on form_schemas.default_crowd_type_id (Task 2 of
this session restores it).
Configuration:
- phpunit.xml: DB_CONNECTION=sqlite (:memory:) replaced with mysql
pointing at crewli_test database (127.0.0.1:3306, crewli/secret)
- Makefile: new test-db-create target creates crewli_test in the
bm_mysql Docker container; make test ensures it exists before
running suite
Latent-bug surfacing — fixes that MySQL exposed:
1. form_submissions.idempotency_key was declared `ulid()` (VARCHAR 26)
while FormRequest validates `string|max:30`. SQLite ignored the cap;
MySQL truncated and rejected. Column widened to string(30) to match
validation.
2. FormFieldValidationRuleService / FormFieldConfigService /
FormFieldBindingService::snapshotShapesFor — toJsonShape iterated
collection in DB-default order (insertion-stable on SQLite, undefined
on MySQL). Schema_snapshot bytes drifted across re-emits, breaking
audit-replay. Added `->sortBy('id')` (ULID = insertion-order
semantics, deterministic) on all three.
3. FormSubmissionObserverTest::test_denormalized_indexes_exist queried
sqlite_master directly. Replaced with the cross-engine
information_schema.STATISTICS query (the real production check is
on MySQL anyway).
4. JSON column key order non-determinism: MySQL JSON columns may
round-trip associative-array keys in a different order than they
were inserted. assertSame on JSON-derived associative arrays now
uses assertEquals (structural equality) where the test was previously
over-asserting on key order:
- ConditionalLogicActivityLogPayloadTest
- ConditionalLogicBackfillTest::test_rollback_reconstructs_canonical_json
- FormFieldBindingMigrationTest::test_rollback_reconstructs_json_and_drops_table
- FormFieldOptionServiceAndScopeTest::test_replace_options_emits_activity_log_on_field_only
- FormFieldOptionsActivityLogTest::test_field_updated_payload_contains_options_diff_when_options_change
- FormFieldOptionsBackfillTest::test_forward_migration_backfills_rows_strips_translations_and_rewrites_snapshot
- FormFieldOptionsSnapshotAndStrictRequestTest::test_submission_snapshot_embeds_rich_shape_options
5. Backfill / migration tests (4 classes, 21 tests) ran migrate:rollback
then migrate inside RefreshDatabase's wrapping transaction. MySQL
DDL implicit-commits the surrounding transaction, leaving Laravel
unable to ROLLBACK TO SAVEPOINT at end-of-test (1305 SAVEPOINT
does not exist). Replaced RefreshDatabase with a per-test
migrate:fresh in setUp + RefreshDatabaseState::\$migrated = false to
force the next RefreshDatabase test to re-migrate cleanly:
- FormFieldBindingMigrationTest
- ConditionalLogicBackfillTest
- FormFieldOptionsBackfillTest
- FormFieldValidationRuleBackfillTest
All 1386 tests now pass on MySQL. Larastan baseline unchanged.
Refs: WS-6 session 2.5 deviation #1 cleanup, RFC-WS-6.md v1.1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
428 lines
17 KiB
PHP
428 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\FormBuilder\Options;
|
|
|
|
use App\Models\FormBuilder\FormSchema;
|
|
use App\Models\Organisation;
|
|
use Illuminate\Foundation\Testing\RefreshDatabaseState;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Rolls back the WS-5d backfill migration, seeds pre-WS-5d JSON into
|
|
* `form_fields.options`, `form_field_library.options`, and the
|
|
* snapshot blobs, then runs the migration forward and back asserting:
|
|
*
|
|
* - Forward: rows land in form_field_options with the correct
|
|
* owner_type/owner_id/value/label/sort_order and translations
|
|
* stripped from the per-locale parallel options[] arrays;
|
|
* snapshots rewritten to rich shape.
|
|
* - Backward: the rollback pair reconstructs the pre-WS-5d JSON
|
|
* shape on every owner row + snapshot.
|
|
*/
|
|
final class FormFieldOptionsBackfillTest extends TestCase
|
|
{
|
|
// Migration tests run DDL inside the test body; RefreshDatabase + MySQL
|
|
// implicit-commit the savepoint, breaking ROLLBACK TO SAVEPOINT at
|
|
// end-of-test (1305 SAVEPOINT does not exist). Use migrate:fresh per
|
|
// test for a clean baseline without the txn wrapper.
|
|
//
|
|
// RefreshDatabaseState::$migrated = false forces the NEXT RefreshDatabase
|
|
// test to re-migrate fresh, so any data this test commits doesn't leak.
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Artisan::call('migrate:fresh');
|
|
RefreshDatabaseState::$migrated = false;
|
|
}
|
|
|
|
public function test_forward_migration_backfills_rows_strips_translations_and_rewrites_snapshots(): void
|
|
{
|
|
// 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' => 2])->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];
|
|
// assertEquals: MySQL JSON columns may reorder associative-array
|
|
// keys on round-trip; structural equality is the contract here.
|
|
$this->assertEquals([
|
|
['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']],
|
|
], $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);
|
|
// assertEquals: MySQL JSON columns may reorder associative-array
|
|
// keys on round-trip; structural equality is the contract here.
|
|
$this->assertEquals(
|
|
[
|
|
['value' => 'A', 'label' => 'A', 'sort_order' => 0],
|
|
['value' => 'B', 'label' => 'B', 'sort_order' => 1],
|
|
],
|
|
$tplSnap['fields'][0]['options'],
|
|
);
|
|
}
|
|
|
|
public function test_rollback_reconstructs_json_columns_and_snapshots(): void
|
|
{
|
|
$this->artisan('migrate:rollback', ['--step' => 2])->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' => 2])->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' => 2])->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' => 2])->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' => 2])->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' => 2])->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' => 2])->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' => 2])->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' => 2])->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;
|
|
}
|
|
}
|