Files
crewli/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php
bert.hausmans 3d323bf55f 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>
2026-04-29 00:10:56 +02:00

280 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Bindings;
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 both WS-5a migrations (drop-columns + create-table), seeds
* pre-migration JSON into `form_fields.binding` and
* `form_field_library.default_binding`, then runs the migrations forward
* and back asserting:
*
* - forward: rows land in form_field_bindings with the correct
* owner_type/owner_id + translated columns; legacy JSON columns are
* dropped afterwards.
* - backward: the rollback pair genuinely reconstructs the JSON shape
* before dropping the table.
*
* The "roll back both steps" contract is explicitly documented in
* `2026_04_25_100001_drop_binding_json_columns.php`.
*/
final class FormFieldBindingMigrationTest extends TestCase
{
// 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
{
// Roll back to pre-WS-5a state: 3 WS-5d migrations (drop-options-cols,
// backfill-options, create-options) + 4 WS-5c migrations
// (drop-conditional-logic-col, backfill-conditional-logic,
// create-conditional-logic-conditions,
// create-conditional-logic-groups) + 5 WS-5b migrations
// (drop-validation-cols, configs-backfill, create-configs,
// validation-rules-backfill, create-validation-rules) +
// 2 WS-6 migrations (action-failures, apply-status) +
// 2 WS-5a migrations (drop-binding-cols, create-bindings) = 16.
$this->artisan('migrate:rollback', ['--step' => 17])->assertSuccessful();
$this->assertFalse(Schema::hasTable('form_field_bindings'));
$this->assertTrue(Schema::hasColumn('form_fields', 'binding'));
$this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding'));
[$fieldAId, $fieldCId, $fieldDId] = $this->seedFieldsWithBindingJson();
[$libAId, $libCId] = $this->seedLibraryWithBindingJson();
$this->artisan('migrate')->assertSuccessful();
$this->assertTrue(Schema::hasTable('form_field_bindings'));
$this->assertFalse(Schema::hasColumn('form_fields', 'binding'));
$this->assertFalse(Schema::hasColumn('form_field_library', 'default_binding'));
$rows = DB::table('form_field_bindings')->get();
$this->assertCount(5, $rows, 'Expected 3 field + 2 library rows');
$fieldRowA = DB::table('form_field_bindings')
->where('owner_type', 'form_field')
->where('owner_id', $fieldAId)
->first();
$this->assertSame('person', $fieldRowA->target_entity);
$this->assertSame('email', $fieldRowA->target_attribute);
$this->assertSame('entity_owned', $fieldRowA->mode);
$this->assertNull($fieldRowA->sync_direction);
$this->assertSame('overwrite', $fieldRowA->merge_strategy);
$this->assertSame(50, (int) $fieldRowA->trust_level);
$this->assertSame(0, (int) $fieldRowA->is_identity_key);
$fieldRowC = DB::table('form_field_bindings')
->where('owner_type', 'form_field')
->where('owner_id', $fieldCId)
->first();
$this->assertSame('mirrored', $fieldRowC->mode);
$this->assertSame('write_on_submit', $fieldRowC->sync_direction);
$this->assertSame('user_profile', $fieldRowC->target_entity);
$this->assertSame('emergency_contact_name', $fieldRowC->target_attribute);
$fieldRowD = DB::table('form_field_bindings')
->where('owner_type', 'form_field')
->where('owner_id', $fieldDId)
->first();
$this->assertSame('entity_owned', $fieldRowD->mode);
$libRowA = DB::table('form_field_bindings')
->where('owner_type', 'form_field_library')
->where('owner_id', $libAId)
->first();
$this->assertSame('person', $libRowA->target_entity);
$this->assertSame('first_name', $libRowA->target_attribute);
$this->assertSame('entity_owned', $libRowA->mode);
$libRowC = DB::table('form_field_bindings')
->where('owner_type', 'form_field_library')
->where('owner_id', $libCId)
->first();
$this->assertSame('mirrored', $libRowC->mode);
}
public function test_rollback_reconstructs_json_and_drops_table(): void
{
// 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();
$this->artisan('migrate')->assertSuccessful();
// Fully-forward state: binding columns gone, rows in form_field_bindings.
$this->assertFalse(Schema::hasColumn('form_fields', 'binding'));
$this->assertSame(5, DB::table('form_field_bindings')->count());
// Step back over WS-5d (3 migrations) + WS-5c (4 migrations) +
// WS-6 (2 migrations) + WS-5b (5 migrations) in one go → restores
// the pre-WS-5b state (conditional-logic, validation-rules, configs
// and options tables gone, validation_rules + options JSON columns
// reappear on source tables; binding contract intact).
$this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful();
$this->assertFalse(Schema::hasTable('form_field_options'));
$this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups'));
$this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions'));
$this->assertFalse(Schema::hasTable('form_field_validation_rules'));
$this->assertFalse(Schema::hasTable('form_field_configs'));
$this->assertTrue(Schema::hasTable('form_field_bindings'));
// Step back over drop_binding_json_columns → columns reappear empty.
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
$this->assertTrue(Schema::hasColumn('form_fields', 'binding'));
$this->assertNull(DB::table('form_fields')->where('id', $fieldAId)->value('binding'));
// Step back over create_form_field_bindings → JSON reconstructed.
$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->assertEquals([
'mode' => 'entity_owned',
'entity' => 'person',
'column' => 'email',
], json_decode((string) $field->binding, true));
$lib = DB::table('form_field_library')->where('id', $libAId)->first();
$this->assertNotNull($lib->default_binding);
$this->assertEquals([
'mode' => 'entity_owned',
'entity' => 'person',
'column' => 'first_name',
], json_decode((string) $lib->default_binding, true));
}
/** @return array{0:string,1:string,2:string} */
private function seedFieldsWithBindingJson(): array
{
$org = Organisation::factory()->create();
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
$fieldA = (string) Str::ulid();
$fieldC = (string) Str::ulid();
$fieldD = (string) Str::ulid();
DB::table('form_fields')->insert([
[
'id' => $fieldA,
'form_schema_id' => $schema->id,
'field_type' => 'EMAIL',
'slug' => 'email',
'label' => 'E-mail',
'binding' => json_encode(['mode' => 'entity_owned', 'entity' => 'person', 'column' => 'email']),
'value_storage_hint' => 'indexed',
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
],
[
'id' => $fieldC,
'form_schema_id' => $schema->id,
'field_type' => 'TEXT',
'slug' => 'noodcontact',
'label' => 'Noodcontact',
'binding' => json_encode([
'mode' => 'mirrored',
'entity' => 'user_profile',
'column' => 'emergency_contact_name',
'sync_direction' => 'write_on_submit',
]),
'value_storage_hint' => 'indexed',
'sort_order' => 1,
'created_at' => now(),
'updated_at' => now(),
],
[
'id' => $fieldD,
'form_schema_id' => $schema->id,
'field_type' => 'TEXT',
'slug' => 'voornaam',
'label' => 'Voornaam',
'binding' => json_encode(['mode' => 'entity_owned', 'entity' => 'person', 'column' => 'first_name']),
'value_storage_hint' => 'indexed',
'sort_order' => 2,
'created_at' => now(),
'updated_at' => now(),
],
]);
return [$fieldA, $fieldC, $fieldD];
}
/** @return array{0:string,1:string} */
private function seedLibraryWithBindingJson(): array
{
$org = Organisation::factory()->create();
$libA = (string) Str::ulid();
$libC = (string) Str::ulid();
DB::table('form_field_library')->insert([
[
'id' => $libA,
'organisation_id' => $org->id,
'name' => 'Voornaam bibliotheek',
'slug' => 'voornaam-lib',
'field_type' => 'TEXT',
'label' => 'Voornaam',
'default_binding' => json_encode(['mode' => 'entity_owned', 'entity' => 'person', 'column' => 'first_name']),
'default_is_required' => false,
'default_is_filterable' => false,
'usage_count' => 0,
'is_system' => false,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'id' => $libC,
'organisation_id' => $org->id,
'name' => 'Noodcontact bibliotheek',
'slug' => 'noodcontact-lib',
'field_type' => 'TEXT',
'label' => 'Noodcontact',
'default_binding' => json_encode([
'mode' => 'mirrored',
'entity' => 'user_profile',
'column' => 'emergency_contact_phone',
'sync_direction' => 'write_on_submit',
]),
'default_is_required' => false,
'default_is_filterable' => false,
'usage_count' => 0,
'is_system' => false,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
],
]);
return [$libA, $libC];
}
}