chore(test): switch test database from SQLite to MySQL (WS-6)

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>
This commit is contained in:
2026-04-28 12:43:17 +02:00
parent 7869843d6e
commit 3d323bf55f
15 changed files with 139 additions and 39 deletions

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -23,8 +23,12 @@
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_HOST" value="127.0.0.1"/>
<env name="DB_PORT" value="3306"/>
<env name="DB_DATABASE" value="crewli_test"/>
<env name="DB_USERNAME" value="crewli"/>
<env name="DB_PASSWORD" value="secret"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>

View File

@@ -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',

View File

@@ -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()

View File

@@ -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'],

View File

@@ -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);

View File

@@ -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'),
);

View File

@@ -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],

View File

@@ -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],

View File

@@ -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],

View File

@@ -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
{