From 3d323bf55fa6b70eb7358665301025441401bbc4 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Tue, 28 Apr 2026 12:43:17 +0200 Subject: [PATCH] chore(test): switch test database from SQLite to MySQL (WS-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Makefile | 14 +++++++++- .../FormBuilder/FormFieldBindingService.php | 6 +++- .../FormBuilder/FormFieldConfigService.php | 5 +++- .../FormFieldValidationRuleService.php | 5 +++- ...9_100006_create_form_submissions_table.php | 6 +++- api/phpunit.xml | 8 ++++-- .../FormFieldBindingMigrationTest.php | 28 +++++++++++++++---- ...ConditionalLogicActivityLogPayloadTest.php | 19 +++++++------ .../ConditionalLogicBackfillTest.php | 21 ++++++++++++-- .../FormSubmissionObserverTest.php | 9 +++--- .../FormFieldOptionServiceAndScopeTest.php | 4 ++- .../FormFieldOptionsActivityLogTest.php | 6 ++-- .../Options/FormFieldOptionsBackfillTest.php | 25 ++++++++++++++--- ...eldOptionsSnapshotAndStrictRequestTest.php | 5 ++-- .../FormFieldValidationRuleBackfillTest.php | 17 +++++++++-- 15 files changed, 139 insertions(+), 39 deletions(-) diff --git a/Makefile b/Makefile index 43d80757..775cb205 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help services services-stop api app portal docs +.PHONY: help services services-stop api app portal docs migrate fresh db-shell test test-db-create # Colors GREEN := \033[0;32m @@ -26,6 +26,10 @@ help: @echo " make migrate Run migrations" @echo " make fresh Fresh migrate + seed" @echo " make db-shell Open MySQL shell" + @echo " make test-db-create Create crewli_test database (one-time)" + @echo "" + @echo " $(YELLOW)Testing:$(NC)" + @echo " make test Run PHPUnit suite (creates crewli_test if needed)" @echo "" services: @@ -69,3 +73,11 @@ fresh: db-shell: @docker exec -it bm_mysql mysql -u crewli -psecret crewli + +test-db-create: + @echo "$(GREEN)Creating crewli_test database...$(NC)" + @docker exec bm_mysql mysql -u root -proot -e "CREATE DATABASE IF NOT EXISTS crewli_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; GRANT ALL PRIVILEGES ON crewli_test.* TO 'crewli'@'%'; FLUSH PRIVILEGES;" + @echo "$(GREEN)✓ crewli_test ready$(NC)" + +test: test-db-create + @cd api && php artisan test diff --git a/api/app/Services/FormBuilder/FormFieldBindingService.php b/api/app/Services/FormBuilder/FormFieldBindingService.php index f61ff87a..4caf4ac3 100644 --- a/api/app/Services/FormBuilder/FormFieldBindingService.php +++ b/api/app/Services/FormBuilder/FormFieldBindingService.php @@ -202,8 +202,12 @@ final class FormFieldBindingService */ public function snapshotShapesFor(iterable $bindings): array { + // ULID id sort = insertion-order semantics, deterministic across DB + // engines. Without it, MySQL returns rows in unspecified order and + // schema_snapshot bytes drift across re-emits — breaks audit replay. + $sorted = collect($bindings)->sortBy(fn (FormFieldBinding $b) => (string) $b->id); $all = []; - foreach ($bindings as $binding) { + foreach ($sorted as $binding) { $all[] = $this->toApplicatorShape($binding); } diff --git a/api/app/Services/FormBuilder/FormFieldConfigService.php b/api/app/Services/FormBuilder/FormFieldConfigService.php index 911c553d..f91c2bd8 100644 --- a/api/app/Services/FormBuilder/FormFieldConfigService.php +++ b/api/app/Services/FormBuilder/FormFieldConfigService.php @@ -120,8 +120,11 @@ final class FormFieldConfigService return null; } + // ULID id sort = insertion-order semantics, deterministic across DB + // engines. Without it, MySQL returns rows in unspecified order and + // schema_snapshot bytes drift across re-emits — breaks audit replay. $out = []; - foreach ($configs as $config) { + foreach ($configs->sortBy('id') as $config) { $type = $config->config_type instanceof FormFieldConfigType ? $config->config_type->value : (string) $config->config_type; diff --git a/api/app/Services/FormBuilder/FormFieldValidationRuleService.php b/api/app/Services/FormBuilder/FormFieldValidationRuleService.php index 305e3faf..559dbfff 100644 --- a/api/app/Services/FormBuilder/FormFieldValidationRuleService.php +++ b/api/app/Services/FormBuilder/FormFieldValidationRuleService.php @@ -129,8 +129,11 @@ final class FormFieldValidationRuleService return null; } + // ULID id sort = insertion-order semantics, deterministic across DB + // engines. Without it, MySQL returns rows in unspecified order and + // schema_snapshot bytes drift across re-emits — breaks audit replay. $out = []; - foreach ($rules as $rule) { + foreach ($rules->sortBy('id') as $rule) { $type = $rule->rule_type instanceof FormFieldValidationRuleType ? $rule->rule_type->value : (string) $rule->rule_type; diff --git a/api/database/migrations/2026_04_19_100006_create_form_submissions_table.php b/api/database/migrations/2026_04_19_100006_create_form_submissions_table.php index 37bbb020..9f880935 100644 --- a/api/database/migrations/2026_04_19_100006_create_form_submissions_table.php +++ b/api/database/migrations/2026_04_19_100006_create_form_submissions_table.php @@ -45,7 +45,11 @@ return new class extends Migration $table->unsignedInteger('submission_duration_seconds')->nullable(); $table->unsignedInteger('auto_save_count')->default(0); - $table->ulid('idempotency_key')->nullable(); + // idempotency_key is a CLIENT-supplied dedup token, not a ULID. + // FormRequest allows string(min:6,max:30); SQLite ignored the + // ulid()=VARCHAR(26) cap, MySQL enforces it. string(30) matches + // validation. + $table->string('idempotency_key', 30)->nullable(); $table->timestamp('anonymised_at')->nullable(); // MEDIUMTEXT for long concatenated submission content; FULLTEXT diff --git a/api/phpunit.xml b/api/phpunit.xml index 81950478..4337eea0 100644 --- a/api/phpunit.xml +++ b/api/phpunit.xml @@ -23,8 +23,12 @@ - - + + + + + + diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index 274ab352..de61cabf 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -6,7 +6,8 @@ namespace Tests\Feature\FormBuilder\Bindings; use App\Models\FormBuilder\FormSchema; use App\Models\Organisation; -use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Foundation\Testing\RefreshDatabaseState; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; @@ -29,7 +30,20 @@ use Tests\TestCase; */ final class FormFieldBindingMigrationTest extends TestCase { - use RefreshDatabase; + // Migration tests run DDL inside the test body (migrate:rollback/migrate); + // RefreshDatabase wraps tests in a transaction, and DDL on MySQL implicit- + // commits the surrounding transaction, leaving Laravel unable to 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_migrations_backfill_rows_from_both_json_sources(): void { @@ -105,8 +119,8 @@ final class FormFieldBindingMigrationTest extends TestCase { // Walk back the full WS-5d + WS-5c + WS-6 + WS-5b + WS-5a stack (16 migrations). $this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful(); - [$fieldAId, , ] = $this->seedFieldsWithBindingJson(); - [$libAId, ] = $this->seedLibraryWithBindingJson(); + [$fieldAId] = $this->seedFieldsWithBindingJson(); + [$libAId] = $this->seedLibraryWithBindingJson(); $this->artisan('migrate')->assertSuccessful(); @@ -136,9 +150,11 @@ final class FormFieldBindingMigrationTest extends TestCase $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); + // assertEquals: MySQL JSON columns may reorder associative-array + // keys on round-trip; structural equality is the contract here. $field = DB::table('form_fields')->where('id', $fieldAId)->first(); $this->assertNotNull($field->binding); - $this->assertSame([ + $this->assertEquals([ 'mode' => 'entity_owned', 'entity' => 'person', 'column' => 'email', @@ -146,7 +162,7 @@ final class FormFieldBindingMigrationTest extends TestCase $lib = DB::table('form_field_library')->where('id', $libAId)->first(); $this->assertNotNull($lib->default_binding); - $this->assertSame([ + $this->assertEquals([ 'mode' => 'entity_owned', 'entity' => 'person', 'column' => 'first_name', diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicActivityLogPayloadTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicActivityLogPayloadTest.php index d518ffe5..a6c803a0 100644 --- a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicActivityLogPayloadTest.php +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicActivityLogPayloadTest.php @@ -75,15 +75,18 @@ final class ConditionalLogicActivityLogPayloadTest extends TestCase $this->assertNotNull($updated, 'field.updated row must exist'); $properties = $updated->properties; - // Use json_encode comparison to avoid associative-array key-order traps — - // mirrors ConditionalLogicSnapshotAndResourceParityTest. - $this->assertSame( - json_encode($oldShape), - json_encode($properties->get('old')['conditional_logic'] ?? null), + // Structural comparison (assertEquals): MySQL JSON columns may + // return associative-array keys in a different order than they were + // inserted; semantically the data is unchanged, so use loose + // equality. Strict json_encode comparison would couple this test to + // a specific DB engine's JSON key-order normalization. + $this->assertEquals( + $oldShape, + $properties->get('old')['conditional_logic'] ?? null, ); - $this->assertSame( - json_encode($newShape), - json_encode($properties->get('new')['conditional_logic'] ?? null), + $this->assertEquals( + $newShape, + $properties->get('new')['conditional_logic'] ?? null, ); $semantic = Activity::query() diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php index 2d07691c..893ecd96 100644 --- a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php @@ -6,7 +6,8 @@ namespace Tests\Feature\FormBuilder\ConditionalLogic; use App\Models\FormBuilder\FormSchema; use App\Models\Organisation; -use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Foundation\Testing\RefreshDatabaseState; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; @@ -27,7 +28,19 @@ use Tests\TestCase; */ final class ConditionalLogicBackfillTest extends TestCase { - use RefreshDatabase; + // 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_backfill_builds_nested_tree_from_legacy_json(): void { @@ -163,7 +176,9 @@ final class ConditionalLogicBackfillTest extends TestCase ->value('conditional_logic'); $this->assertNotNull($reconstructed); $json = json_decode((string) $reconstructed, true); - $this->assertSame([ + // assertEquals: MySQL JSON columns may reorder associative-array + // keys on round-trip; structural equality is the contract here. + $this->assertEquals([ 'show_when' => [ 'all' => [ ['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'], diff --git a/api/tests/Feature/FormBuilder/FormSubmissionObserverTest.php b/api/tests/Feature/FormBuilder/FormSubmissionObserverTest.php index 38cc57a3..77337e9d 100644 --- a/api/tests/Feature/FormBuilder/FormSubmissionObserverTest.php +++ b/api/tests/Feature/FormBuilder/FormSubmissionObserverTest.php @@ -8,7 +8,6 @@ use App\Models\Event; use App\Models\FormBuilder\FormSchema; use App\Models\FormBuilder\FormSubmission; use App\Models\Organisation; -use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Route; @@ -89,7 +88,7 @@ final class FormSubmissionObserverTest extends TestCase Route::get( '/_test/events/{event}/submissions', function (Event $event) use ($schema): array { - $submission = new FormSubmission(); + $submission = new FormSubmission; $submission->form_schema_id = $schema->id; $submission->status = 'draft'; $submission->is_test = false; @@ -117,7 +116,7 @@ final class FormSubmissionObserverTest extends TestCase // Direct factory $s1 = FormSubmission::factory()->for($schema, 'schema')->create(); // Direct new() + save() - $s2 = new FormSubmission(); + $s2 = new FormSubmission; $s2->form_schema_id = $schema->id; $s2->status = 'draft'; $s2->is_test = false; @@ -141,8 +140,10 @@ final class FormSubmissionObserverTest extends TestCase public function test_denormalized_indexes_exist(): void { // Indexes named in the migration — addendum Q2 rapportage-hot. + // MySQL: query information_schema.STATISTICS. $indexNames = collect(DB::select( - "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='form_submissions'" + 'SELECT INDEX_NAME AS name FROM information_schema.STATISTICS ' + ."WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'form_submissions'" ))->pluck('name')->toArray(); $this->assertContains('fs_org_status_idx', $indexNames); diff --git a/api/tests/Feature/FormBuilder/Options/FormFieldOptionServiceAndScopeTest.php b/api/tests/Feature/FormBuilder/Options/FormFieldOptionServiceAndScopeTest.php index 3d05ebd0..26ece352 100644 --- a/api/tests/Feature/FormBuilder/Options/FormFieldOptionServiceAndScopeTest.php +++ b/api/tests/Feature/FormBuilder/Options/FormFieldOptionServiceAndScopeTest.php @@ -191,7 +191,9 @@ final class FormFieldOptionServiceAndScopeTest extends TestCase ->where('description', 'field.options_replaced') ->first(); $this->assertNotNull($fieldEvent); - $this->assertSame( + // assertEquals: MySQL JSON columns may reorder associative-array + // keys on round-trip; semantic content is what matters here. + $this->assertEquals( [['value' => 'a', 'label' => 'A', 'sort_order' => 0]], $fieldEvent->properties->get('options'), ); diff --git a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsActivityLogTest.php b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsActivityLogTest.php index bc53e8c1..8f5aaca1 100644 --- a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsActivityLogTest.php +++ b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsActivityLogTest.php @@ -61,14 +61,16 @@ final class FormFieldOptionsActivityLogTest extends TestCase $payload = $event->properties->toArray(); $this->assertArrayHasKey('options', $payload['old']); $this->assertArrayHasKey('options', $payload['new']); - $this->assertSame( + // assertEquals: MySQL JSON columns may reorder associative-array + // keys on round-trip; structural equality is the contract. + $this->assertEquals( [ ['value' => 'a', 'label' => 'a', 'sort_order' => 0], ['value' => 'b', 'label' => 'b', 'sort_order' => 1], ], $payload['old']['options'], ); - $this->assertSame( + $this->assertEquals( [ ['value' => 'a', 'label' => 'A', 'sort_order' => 0], ['value' => 'b', 'label' => 'b', 'sort_order' => 1], diff --git a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php index 79e623ca..90bc2a6a 100644 --- a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php +++ b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php @@ -6,7 +6,8 @@ namespace Tests\Feature\FormBuilder\Options; use App\Models\FormBuilder\FormSchema; use App\Models\Organisation; -use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Foundation\Testing\RefreshDatabaseState; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; @@ -26,7 +27,19 @@ use Tests\TestCase; */ final class FormFieldOptionsBackfillTest extends TestCase { - use RefreshDatabase; + // 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 { @@ -86,7 +99,9 @@ final class FormFieldOptionsBackfillTest extends TestCase $submission = DB::table('form_submissions')->where('id', $submissionId)->first(); $snapshot = json_decode((string) $submission->schema_snapshot, true); $field = $snapshot['fields'][0]; - $this->assertSame([ + // 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']], @@ -102,7 +117,9 @@ final class FormFieldOptionsBackfillTest extends TestCase // Template snapshot rewritten the same way. $template = DB::table('form_templates')->where('id', $templateId)->first(); $tplSnap = json_decode((string) $template->schema_snapshot, true); - $this->assertSame( + // 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], diff --git a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsSnapshotAndStrictRequestTest.php b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsSnapshotAndStrictRequestTest.php index 5263add5..75e85a24 100644 --- a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsSnapshotAndStrictRequestTest.php +++ b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsSnapshotAndStrictRequestTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Tests\Feature\FormBuilder\Options; use App\Enums\FormBuilder\FormFieldType; -use App\Enums\FormBuilder\FormPurpose; use App\Models\FormBuilder\FormField; use App\Models\FormBuilder\FormSchema; use App\Models\Organisation; @@ -47,7 +46,9 @@ final class FormFieldOptionsSnapshotAndStrictRequestTest extends TestCase $snapshot = $draft->fresh()->schema_snapshot; $this->assertIsArray($snapshot); $field = collect($snapshot['fields'])->firstWhere('slug', 'shirtmaat'); - $this->assertSame( + // assertEquals: MySQL JSON columns may reorder associative-array + // keys on round-trip; structural equality is the contract. + $this->assertEquals( [ ['value' => 'XS', 'label' => 'XS', 'sort_order' => 0], ['value' => 'S', 'label' => 'S', 'sort_order' => 1], diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php index 2e5daa61..1bd9d7a5 100644 --- a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php @@ -6,7 +6,8 @@ namespace Tests\Feature\FormBuilder\ValidationRules; use App\Models\FormBuilder\FormSchema; use App\Models\Organisation; -use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Foundation\Testing\RefreshDatabaseState; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; @@ -27,7 +28,19 @@ use Tests\TestCase; */ final class FormFieldValidationRuleBackfillTest extends TestCase { - use RefreshDatabase; + // 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_with_field_type_dispatch(): void {