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>
445 lines
18 KiB
PHP
445 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\FormBuilder\Options;
|
|
|
|
use App\Exceptions\FormBuilder\InvalidOptionSpecException;
|
|
use App\Models\FormBuilder\FormField;
|
|
use App\Models\FormBuilder\FormFieldLibrary;
|
|
use App\Models\FormBuilder\FormFieldOption;
|
|
use App\Models\FormBuilder\FormSchema;
|
|
use App\Models\Organisation;
|
|
use App\Models\Scopes\FormFieldOptionScope;
|
|
use App\Services\FormBuilder\FormFieldOptionService;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Routing\Route;
|
|
use Spatie\Activitylog\Models\Activity;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Consolidated coverage for `form_field_options` — model relation,
|
|
* scope isolation, cascade, and service-layer contract (replace, copy,
|
|
* toJsonShape, assertSpecsValid). Mirrors the structure of the
|
|
* configs / validation-rules test suites (WS-5b).
|
|
*/
|
|
final class FormFieldOptionServiceAndScopeTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_field_morph_many_options_and_owner_morphto_roundtrip(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
|
|
|
FormFieldOption::factory()->forField($field)->create([
|
|
'value' => 'xs', 'label' => 'XS', 'sort_order' => 0,
|
|
]);
|
|
FormFieldOption::factory()->forField($field)->create([
|
|
'value' => 'sm', 'label' => 'S', 'sort_order' => 1,
|
|
]);
|
|
|
|
// Commit 1 coexistence: form_fields.options JSON cast still exists,
|
|
// so $field->options resolves to the array attribute rather than
|
|
// the morphMany. Explicit relation call until commit 3 drops the
|
|
// cast.
|
|
$options = $field->fresh()->options()->get();
|
|
$this->assertCount(2, $options);
|
|
$this->assertSame(['xs', 'sm'], $options->pluck('value')->all());
|
|
$this->assertSame(FormField::class, $options->first()->fresh()->owner::class);
|
|
}
|
|
|
|
public function test_library_morph_many_options_resolves(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]);
|
|
FormFieldOption::factory()->forLibrary($library)->create([
|
|
'value' => 'a', 'label' => 'A', 'sort_order' => 0,
|
|
]);
|
|
|
|
$this->assertSame(
|
|
FormFieldLibrary::class,
|
|
$library->fresh()->options()->get()->first()->fresh()->owner::class,
|
|
);
|
|
}
|
|
|
|
public function test_to_json_shape_omits_empty_translations(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
|
$option = FormFieldOption::factory()->forField($field)->create([
|
|
'value' => 'a', 'label' => 'A', 'sort_order' => 0, 'translations' => null,
|
|
]);
|
|
|
|
$this->assertSame(
|
|
['value' => 'a', 'label' => 'A', 'sort_order' => 0],
|
|
$option->toJsonShape(),
|
|
);
|
|
}
|
|
|
|
public function test_to_json_shape_includes_translations(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
|
$option = FormFieldOption::factory()->forField($field)->create([
|
|
'value' => 'sm', 'label' => 'Small', 'sort_order' => 1,
|
|
'translations' => ['nl' => 'Klein', 'de' => 'Klein'],
|
|
]);
|
|
|
|
$this->assertSame(
|
|
[
|
|
'value' => 'sm',
|
|
'label' => 'Small',
|
|
'sort_order' => 1,
|
|
'translations' => ['nl' => 'Klein', 'de' => 'Klein'],
|
|
],
|
|
$option->toJsonShape(),
|
|
);
|
|
}
|
|
|
|
public function test_unique_constraint_blocks_duplicate_value_per_owner(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
|
|
|
FormFieldOption::factory()->forField($field)->create([
|
|
'value' => 'dup', 'label' => 'A', 'sort_order' => 0,
|
|
]);
|
|
|
|
$this->expectException(QueryException::class);
|
|
FormFieldOption::factory()->forField($field)->create([
|
|
'value' => 'dup', 'label' => 'B', 'sort_order' => 1,
|
|
]);
|
|
}
|
|
|
|
public function test_options_for_returns_options_in_sort_order(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
|
|
|
FormFieldOption::factory()->forField($field)->create(['value' => 'c', 'label' => 'C', 'sort_order' => 2]);
|
|
FormFieldOption::factory()->forField($field)->create(['value' => 'a', 'label' => 'A', 'sort_order' => 0]);
|
|
FormFieldOption::factory()->forField($field)->create(['value' => 'b', 'label' => 'B', 'sort_order' => 1]);
|
|
|
|
$options = app(FormFieldOptionService::class)->optionsFor($field);
|
|
$this->assertSame(['a', 'b', 'c'], $options->pluck('value')->all());
|
|
}
|
|
|
|
public function test_replace_options_creates_rows_transactionally(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
|
|
|
$service = app(FormFieldOptionService::class);
|
|
$service->replaceOptions($field, [
|
|
['value' => 'red', 'label' => 'Red', 'sort_order' => 0],
|
|
['value' => 'green', 'label' => 'Green', 'sort_order' => 1],
|
|
]);
|
|
|
|
$this->assertSame(['red', 'green'], $service->optionsFor($field)->pluck('value')->all());
|
|
}
|
|
|
|
public function test_replace_options_invalid_specs_roll_back_no_partial_state(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
|
|
|
$service = app(FormFieldOptionService::class);
|
|
$service->replaceOptions($field, [
|
|
['value' => 'old', 'label' => 'Old', 'sort_order' => 0],
|
|
]);
|
|
|
|
try {
|
|
$service->replaceOptions($field, [
|
|
['value' => 'new1', 'label' => 'New 1', 'sort_order' => 0],
|
|
['value' => '', 'label' => 'bad', 'sort_order' => 1],
|
|
]);
|
|
$this->fail('Expected InvalidOptionSpecException.');
|
|
} catch (InvalidOptionSpecException) {
|
|
// expected
|
|
}
|
|
|
|
$this->assertSame(['old'], $service->optionsFor($field)->pluck('value')->all());
|
|
}
|
|
|
|
public function test_replace_options_emits_activity_log_on_field_only(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
|
$library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]);
|
|
$service = app(FormFieldOptionService::class);
|
|
|
|
$service->replaceOptions($field, [
|
|
['value' => 'a', 'label' => 'A', 'sort_order' => 0],
|
|
]);
|
|
$service->replaceOptions($library, [
|
|
['value' => 'b', 'label' => 'B', 'sort_order' => 0],
|
|
]);
|
|
|
|
$fieldEvent = Activity::query()
|
|
->where('subject_type', 'form_field')
|
|
->where('subject_id', $field->id)
|
|
->where('description', 'field.options_replaced')
|
|
->first();
|
|
$this->assertNotNull($fieldEvent);
|
|
// 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'),
|
|
);
|
|
|
|
$this->assertNull(Activity::query()
|
|
->where('subject_type', 'form_field_library')
|
|
->where('description', 'field.options_replaced')
|
|
->first());
|
|
}
|
|
|
|
public function test_copy_options_clones_every_row_including_translations_and_sort_order(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]);
|
|
FormFieldOption::factory()->forLibrary($library)->create([
|
|
'value' => 'one', 'label' => 'One', 'sort_order' => 1,
|
|
'translations' => ['nl' => 'Een'],
|
|
]);
|
|
FormFieldOption::factory()->forLibrary($library)->create([
|
|
'value' => 'two', 'label' => 'Two', 'sort_order' => 0,
|
|
'translations' => null,
|
|
]);
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
|
|
|
app(FormFieldOptionService::class)->copyOptions($library, $field);
|
|
|
|
$copied = app(FormFieldOptionService::class)->optionsFor($field);
|
|
$this->assertCount(2, $copied);
|
|
$this->assertSame(['two', 'one'], $copied->pluck('value')->all());
|
|
$this->assertSame(['nl' => 'Een'], $copied->firstWhere('value', 'one')->translations);
|
|
$this->assertNull($copied->firstWhere('value', 'two')->translations);
|
|
}
|
|
|
|
public function test_copy_options_emits_no_activity_log(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]);
|
|
FormFieldOption::factory()->forLibrary($library)->create(['value' => 'a', 'label' => 'A', 'sort_order' => 0]);
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
|
|
|
app(FormFieldOptionService::class)->copyOptions($library, $field);
|
|
|
|
$this->assertNull(Activity::query()
|
|
->where('description', 'field.options_replaced')
|
|
->first());
|
|
}
|
|
|
|
public function test_to_json_shape_byte_equal_to_contract(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
|
|
|
$service = app(FormFieldOptionService::class);
|
|
$service->replaceOptions($field, [
|
|
['value' => 'red', 'label' => 'Red', 'sort_order' => 0],
|
|
['value' => 'green', 'label' => 'Green', 'sort_order' => 1, 'translations' => ['nl' => 'Groen']],
|
|
]);
|
|
|
|
$shape = $service->toJsonShape($service->optionsFor($field));
|
|
$this->assertSame(
|
|
[
|
|
['value' => 'red', 'label' => 'Red', 'sort_order' => 0],
|
|
['value' => 'green', 'label' => 'Green', 'sort_order' => 1, 'translations' => ['nl' => 'Groen']],
|
|
],
|
|
$shape,
|
|
);
|
|
}
|
|
|
|
public function test_assert_specs_valid_rejects_non_array_spec(): void
|
|
{
|
|
$this->expectException(InvalidOptionSpecException::class);
|
|
app(FormFieldOptionService::class)->assertSpecsValid(['not-an-array']);
|
|
}
|
|
|
|
public function test_assert_specs_valid_rejects_missing_or_oversized_value(): void
|
|
{
|
|
$service = app(FormFieldOptionService::class);
|
|
|
|
$this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([
|
|
['label' => 'L', 'sort_order' => 0],
|
|
]));
|
|
$this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([
|
|
['value' => '', 'label' => 'L', 'sort_order' => 0],
|
|
]));
|
|
$this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([
|
|
['value' => str_repeat('x', 256), 'label' => 'L', 'sort_order' => 0],
|
|
]));
|
|
}
|
|
|
|
public function test_assert_specs_valid_rejects_missing_or_oversized_label(): void
|
|
{
|
|
$service = app(FormFieldOptionService::class);
|
|
|
|
$this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([
|
|
['value' => 'v', 'sort_order' => 0],
|
|
]));
|
|
$this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([
|
|
['value' => 'v', 'label' => '', 'sort_order' => 0],
|
|
]));
|
|
$this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([
|
|
['value' => 'v', 'label' => str_repeat('y', 256), 'sort_order' => 0],
|
|
]));
|
|
}
|
|
|
|
public function test_assert_specs_valid_rejects_bad_sort_order(): void
|
|
{
|
|
$service = app(FormFieldOptionService::class);
|
|
|
|
$this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([
|
|
['value' => 'v', 'label' => 'L'],
|
|
]));
|
|
$this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([
|
|
['value' => 'v', 'label' => 'L', 'sort_order' => '0'],
|
|
]));
|
|
$this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([
|
|
['value' => 'v', 'label' => 'L', 'sort_order' => -1],
|
|
]));
|
|
}
|
|
|
|
public function test_assert_specs_valid_rejects_translations_not_array(): void
|
|
{
|
|
$this->expectException(InvalidOptionSpecException::class);
|
|
app(FormFieldOptionService::class)->assertSpecsValid([
|
|
['value' => 'v', 'label' => 'L', 'sort_order' => 0, 'translations' => 'not-array'],
|
|
]);
|
|
}
|
|
|
|
public function test_assert_specs_valid_rejects_invalid_locale_key(): void
|
|
{
|
|
$this->expectException(InvalidOptionSpecException::class);
|
|
app(FormFieldOptionService::class)->assertSpecsValid([
|
|
['value' => 'v', 'label' => 'L', 'sort_order' => 0, 'translations' => ['XX' => 'bad']],
|
|
]);
|
|
}
|
|
|
|
public function test_assert_specs_valid_rejects_invalid_translated_value(): void
|
|
{
|
|
$service = app(FormFieldOptionService::class);
|
|
|
|
$this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([
|
|
['value' => 'v', 'label' => 'L', 'sort_order' => 0, 'translations' => ['nl' => '']],
|
|
]));
|
|
$this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([
|
|
['value' => 'v', 'label' => 'L', 'sort_order' => 0, 'translations' => ['nl' => str_repeat('a', 256)]],
|
|
]));
|
|
}
|
|
|
|
public function test_assert_specs_valid_rejects_duplicate_values(): void
|
|
{
|
|
$this->expectException(InvalidOptionSpecException::class);
|
|
app(FormFieldOptionService::class)->assertSpecsValid([
|
|
['value' => 'dup', 'label' => 'A', 'sort_order' => 0],
|
|
['value' => 'dup', 'label' => 'B', 'sort_order' => 1],
|
|
]);
|
|
}
|
|
|
|
public function test_scope_isolates_options_per_organisation_both_owner_types(): void
|
|
{
|
|
[$orgA, $fieldA, $libraryA] = $this->seedOrgWithOptions();
|
|
[$orgB, $fieldB, $libraryB] = $this->seedOrgWithOptions();
|
|
|
|
$this->withOrgRoute($orgA);
|
|
$ids = FormFieldOption::query()->pluck('owner_id')->sort()->values()->all();
|
|
$expected = collect([$fieldA->id, $libraryA->id])->sort()->values()->all();
|
|
$this->assertSame($expected, $ids);
|
|
|
|
// Escape hatch.
|
|
$this->assertSame(
|
|
4,
|
|
FormFieldOption::query()->withoutGlobalScope(FormFieldOptionScope::class)->count(),
|
|
);
|
|
$this->assertSame(2, FormFieldOption::query()->count());
|
|
}
|
|
|
|
public function test_cascade_options_deleted_on_field_soft_delete(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
|
FormFieldOption::factory()->forField($field)->create(['value' => 'a', 'label' => 'A']);
|
|
|
|
$this->assertSame(1, FormFieldOption::query()->withoutGlobalScopes()
|
|
->where('owner_id', $field->id)->count());
|
|
|
|
$field->delete(); // soft delete on FormField
|
|
$this->assertSame(0, FormFieldOption::query()->withoutGlobalScopes()
|
|
->where('owner_id', $field->id)->count());
|
|
}
|
|
|
|
public function test_cascade_options_deleted_on_field_force_delete(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
|
FormFieldOption::factory()->forField($field)->create(['value' => 'a', 'label' => 'A']);
|
|
|
|
$field->forceDelete();
|
|
$this->assertSame(0, FormFieldOption::query()->withoutGlobalScopes()
|
|
->where('owner_id', $field->id)->count());
|
|
}
|
|
|
|
public function test_cascade_options_deleted_on_library_delete(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]);
|
|
FormFieldOption::factory()->forLibrary($library)->create(['value' => 'a', 'label' => 'A']);
|
|
|
|
$library->delete();
|
|
$this->assertSame(0, FormFieldOption::query()->withoutGlobalScopes()
|
|
->where('owner_id', $library->id)->count());
|
|
}
|
|
|
|
/** @return array{0:Organisation,1:FormField,2:FormFieldLibrary} */
|
|
private function seedOrgWithOptions(): array
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
$field = FormField::factory()->create(['form_schema_id' => $schema->id]);
|
|
$library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]);
|
|
FormFieldOption::factory()->forField($field)->create(['value' => 'fld-'.$field->id, 'label' => 'F']);
|
|
FormFieldOption::factory()->forLibrary($library)->create(['value' => 'lib-'.$library->id, 'label' => 'L']);
|
|
|
|
return [$org, $field, $library];
|
|
}
|
|
|
|
private function withOrgRoute(Organisation $org): void
|
|
{
|
|
$route = new Route(['GET'], '/_test', static fn () => null);
|
|
$route->bind(request());
|
|
$route->setParameter('organisation', $org);
|
|
request()->setRouteResolver(static fn () => $route);
|
|
}
|
|
|
|
private function assertThrowsInvalidSpec(callable $fn): void
|
|
{
|
|
try {
|
|
$fn();
|
|
} catch (InvalidOptionSpecException) {
|
|
$this->assertTrue(true);
|
|
|
|
return;
|
|
}
|
|
$this->fail('Expected InvalidOptionSpecException, none thrown.');
|
|
}
|
|
}
|