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 {