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>
321 lines
13 KiB
PHP
321 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\FormBuilder\ValidationRules;
|
|
|
|
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-5b commit 1+2 migrations, seeds pre-WS-5b JSON, then
|
|
* runs the migrations forward and back asserting:
|
|
*
|
|
* - Forward: canonical rows land in form_field_validation_rules with
|
|
* the correct field-type dispatch for legacy `min`/`max` ambiguity
|
|
* and `max_priorities` canonicalised to `max_selected`.
|
|
* - Forward: `tag_categories` / `storage_disk` are skipped (handled by
|
|
* commit 5's configs backfill).
|
|
* - Rollback: the rows are serialised back into the JSON column using
|
|
* canonical keys. `required` / `unique` keys are not reconstructed
|
|
* (never migrated).
|
|
*/
|
|
final class FormFieldValidationRuleBackfillTest 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_with_field_type_dispatch(): void
|
|
{
|
|
// Roll back: 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) + 2 WS-6 migrations
|
|
// (action-failures, apply-status) + 5 WS-5b migrations
|
|
// (drop-cols + configs-backfill + create-configs +
|
|
// validation-rules-backfill + create-validation-rules) = 14.
|
|
// Brings us to the pre-WS-5b state: validation_rules JSON column
|
|
// present, no relational tables for WS-5b/c/d.
|
|
$this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful();
|
|
$this->assertFalse(Schema::hasTable('form_field_validation_rules'));
|
|
$this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
|
|
|
|
[$numberId, $textId, $dateId, $sectionPriorityId] = $this->seedFields();
|
|
[$libAId] = $this->seedLibrary();
|
|
|
|
$this->artisan('migrate')->assertSuccessful();
|
|
|
|
$this->assertTrue(Schema::hasTable('form_field_validation_rules'));
|
|
|
|
$numberRules = DB::table('form_field_validation_rules')
|
|
->where('owner_type', 'form_field')
|
|
->where('owner_id', $numberId)
|
|
->get()->keyBy('rule_type');
|
|
$this->assertTrue($numberRules->has('min_value'));
|
|
$this->assertTrue($numberRules->has('max_value'));
|
|
$this->assertSame(16, json_decode((string) $numberRules['min_value']->parameters, true)['value']);
|
|
$this->assertSame(99, json_decode((string) $numberRules['max_value']->parameters, true)['value']);
|
|
|
|
$textRules = DB::table('form_field_validation_rules')
|
|
->where('owner_type', 'form_field')
|
|
->where('owner_id', $textId)
|
|
->get()->keyBy('rule_type');
|
|
$this->assertTrue($textRules->has('min_length'));
|
|
$this->assertTrue($textRules->has('max_length'));
|
|
$this->assertSame(5, json_decode((string) $textRules['min_length']->parameters, true)['value']);
|
|
$this->assertSame(80, json_decode((string) $textRules['max_length']->parameters, true)['value']);
|
|
|
|
$dateRules = DB::table('form_field_validation_rules')
|
|
->where('owner_type', 'form_field')
|
|
->where('owner_id', $dateId)
|
|
->get()->keyBy('rule_type');
|
|
$this->assertTrue($dateRules->has('date_min'));
|
|
$this->assertSame('2026-01-01', json_decode((string) $dateRules['date_min']->parameters, true)['date']);
|
|
|
|
$sectionRules = DB::table('form_field_validation_rules')
|
|
->where('owner_type', 'form_field')
|
|
->where('owner_id', $sectionPriorityId)
|
|
->get()->keyBy('rule_type');
|
|
$this->assertTrue($sectionRules->has('max_selected'), 'max_priorities should canonicalise to max_selected');
|
|
$this->assertSame(3, json_decode((string) $sectionRules['max_selected']->parameters, true)['value']);
|
|
$this->assertFalse($sectionRules->has('max_priorities'), 'legacy key must not survive');
|
|
|
|
$libRules = DB::table('form_field_validation_rules')
|
|
->where('owner_type', 'form_field_library')
|
|
->where('owner_id', $libAId)
|
|
->get()->keyBy('rule_type');
|
|
$this->assertTrue($libRules->has('regex'));
|
|
$this->assertSame(
|
|
'/^[A-Z]{3}$/',
|
|
json_decode((string) $libRules['regex']->parameters, true)['pattern'],
|
|
);
|
|
}
|
|
|
|
public function test_tag_categories_and_storage_disk_skipped_for_commit_5(): void
|
|
{
|
|
// Roll back all WS-5b migrations to reach the pre-WS-5b state
|
|
// (validation_rules JSON column present; no relational tables for
|
|
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
|
|
// + validation-rules-backfill + create-validation-rules = 5.
|
|
$this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful();
|
|
|
|
$fieldId = $this->seedFieldWithJson([
|
|
'field_type' => 'TAG_PICKER',
|
|
'validation_rules' => [
|
|
'tag_categories' => ['Veiligheid'],
|
|
'storage_disk' => 'local',
|
|
],
|
|
]);
|
|
|
|
$this->artisan('migrate')->assertSuccessful();
|
|
|
|
$rows = DB::table('form_field_validation_rules')
|
|
->where('owner_id', $fieldId)
|
|
->get();
|
|
$this->assertCount(0, $rows, 'configs keys must not land in the validation-rules table');
|
|
}
|
|
|
|
public function test_required_and_unique_skipped_with_warn(): void
|
|
{
|
|
// Roll back all WS-5b migrations to reach the pre-WS-5b state
|
|
// (validation_rules JSON column present; no relational tables for
|
|
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
|
|
// + validation-rules-backfill + create-validation-rules = 5.
|
|
$this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful();
|
|
|
|
$fieldId = $this->seedFieldWithJson([
|
|
'field_type' => 'TEXT',
|
|
'validation_rules' => [
|
|
'required' => true,
|
|
'unique' => true,
|
|
'min' => 2,
|
|
],
|
|
]);
|
|
|
|
$this->artisan('migrate')->assertSuccessful();
|
|
|
|
$rows = DB::table('form_field_validation_rules')
|
|
->where('owner_id', $fieldId)
|
|
->pluck('rule_type')
|
|
->all();
|
|
sort($rows);
|
|
$this->assertSame(['min_length'], $rows, 'only min_length should land (required/unique skipped)');
|
|
}
|
|
|
|
public function test_unknown_top_level_key_fails_migration(): void
|
|
{
|
|
// Roll back all WS-5b migrations to reach the pre-WS-5b state
|
|
// (validation_rules JSON column present; no relational tables for
|
|
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
|
|
// + validation-rules-backfill + create-validation-rules = 5.
|
|
$this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful();
|
|
|
|
$this->seedFieldWithJson([
|
|
'field_type' => 'TEXT',
|
|
'validation_rules' => ['nonsense_key' => 42],
|
|
]);
|
|
|
|
$this->expectException(\RuntimeException::class);
|
|
$this->artisan('migrate');
|
|
}
|
|
|
|
public function test_unmapped_field_type_for_min_max_fails_migration(): void
|
|
{
|
|
// Roll back all WS-5b migrations to reach the pre-WS-5b state
|
|
// (validation_rules JSON column present; no relational tables for
|
|
// WS-5b). Step count: drop-cols + configs-backfill + create-configs
|
|
// + validation-rules-backfill + create-validation-rules = 5.
|
|
$this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful();
|
|
|
|
$this->seedFieldWithJson([
|
|
'field_type' => 'BOOLEAN',
|
|
'validation_rules' => ['min' => 1],
|
|
]);
|
|
|
|
$this->expectException(\RuntimeException::class);
|
|
$this->artisan('migrate');
|
|
}
|
|
|
|
public function test_full_wsb_rollback_reconstructs_source_state(): void
|
|
{
|
|
// Architect contract: "the forward+back pair is safe when run as a
|
|
// unit; a partial 'rollback this migration but not its create-table
|
|
// sibling' state is not supported." This test exercises the
|
|
// full-back-then-full-forward cycle — rolling back all WS-5b
|
|
// migrations restores the pre-WS-5b state (columns present on
|
|
// source tables; validation rules relational table gone).
|
|
$this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful();
|
|
[$numberId] = $this->seedFields();
|
|
|
|
$this->artisan('migrate')->assertSuccessful();
|
|
// Post-migration: rows exist, column dropped.
|
|
$this->assertSame(
|
|
2,
|
|
DB::table('form_field_validation_rules')
|
|
->where('owner_id', $numberId)
|
|
->count(),
|
|
);
|
|
$this->assertFalse(Schema::hasColumn('form_fields', 'validation_rules'));
|
|
|
|
// Roll back WS-5b fully → column reappears and carries canonical JSON
|
|
// reconstructed from the relational rows.
|
|
$this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful();
|
|
$this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules'));
|
|
|
|
$field = DB::table('form_fields')->where('id', $numberId)->first();
|
|
$decoded = json_decode((string) $field->validation_rules, true);
|
|
$this->assertSame(16, $decoded['min_value']);
|
|
$this->assertSame(99, $decoded['max_value']);
|
|
}
|
|
|
|
/** @return array{0:string,1:string,2:string,3:string} */
|
|
private function seedFields(): array
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
|
|
$number = (string) Str::ulid();
|
|
$text = (string) Str::ulid();
|
|
$date = (string) Str::ulid();
|
|
$section = (string) Str::ulid();
|
|
|
|
DB::table('form_fields')->insert([
|
|
$this->row($number, $schema->id, 'NUMBER', 'leeftijd',
|
|
['min' => 16, 'max' => 99]),
|
|
$this->row($text, $schema->id, 'TEXT', 'postcode',
|
|
['min' => 5, 'max' => 80]),
|
|
$this->row($date, $schema->id, 'DATE', 'startdatum',
|
|
['min' => '2026-01-01']),
|
|
$this->row($section, $schema->id, 'SECTION_PRIORITY', 'sectie-voorkeur',
|
|
['max_priorities' => 3]),
|
|
]);
|
|
|
|
return [$number, $text, $date, $section];
|
|
}
|
|
|
|
/** @param array<string, mixed> $validationRules */
|
|
private function row(string $id, string $schemaId, string $fieldType, string $slug, array $validationRules): array
|
|
{
|
|
return [
|
|
'id' => $id,
|
|
'form_schema_id' => $schemaId,
|
|
'field_type' => $fieldType,
|
|
'slug' => $slug,
|
|
'label' => $slug,
|
|
'validation_rules' => json_encode($validationRules),
|
|
'value_storage_hint' => 'json',
|
|
'sort_order' => 0,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
];
|
|
}
|
|
|
|
/** @return array{0:string} */
|
|
private function seedLibrary(): array
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
|
|
$lib = (string) Str::ulid();
|
|
DB::table('form_field_library')->insert([
|
|
[
|
|
'id' => $lib,
|
|
'organisation_id' => $org->id,
|
|
'name' => 'License plate bibliotheek',
|
|
'slug' => 'kenteken-lib',
|
|
'field_type' => 'TEXT',
|
|
'label' => 'Kenteken',
|
|
'validation_rules' => json_encode(['regex' => '/^[A-Z]{3}$/']),
|
|
'default_is_required' => false,
|
|
'default_is_filterable' => false,
|
|
'usage_count' => 0,
|
|
'is_system' => false,
|
|
'is_active' => true,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
],
|
|
]);
|
|
|
|
return [$lib];
|
|
}
|
|
|
|
/** @param array<string, mixed> $attrs */
|
|
private function seedFieldWithJson(array $attrs): 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' => $attrs['field_type'],
|
|
'slug' => 'f-'.Str::lower(Str::random(4)),
|
|
'label' => 'field',
|
|
'validation_rules' => json_encode($attrs['validation_rules']),
|
|
'value_storage_hint' => 'json',
|
|
'sort_order' => 0,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]]);
|
|
|
|
return $id;
|
|
}
|
|
}
|