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
{