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.'); } }