Files
crewli/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php
bert.hausmans e43dd60756 test(timetable): Phase C — artist domain coverage + cross-cutting fixes
New Phase C test files:
- tests/Unit/Models/Artist/ArtistDomainModelsTest.php — relationships,
  casts, soft-delete trait presence, slug uniqueness within/across
  organisations, isParked() helper, AdvanceSection's primary scope,
  PURPOSE_SUBJECT_FQCN['artist'] resolves to instantiable class.
- tests/Feature/Artist/ArtistEngagementObserverTest.php — auto-fill
  organisation_id from artist, cross-tenant guard throws, soft-delete
  cascades to performances + hard-deletes advance_sections.
- tests/Feature/Artist/PerformanceObserverTest.php — version starts
  at 0, increments by 1 per UPDATE, no bump on no-op save.
- tests/Feature/Artist/ArtistDomainScopeLeakageTest.php — 5 scoped
  models (Artist/Genre/Engagement direct + Stage/Performance FK-chain)
  isolate cross-org queries.
- tests/Feature/Artist/ArtistTimetableDevSeederTest.php — fixture-count
  smoke (4 stages, 12 stage_days, 6 artists, 12 engagements,
  13 performances incl. 1 parked).

Cross-cutting fixes that Phase C surfaced:
- AppServiceProvider: morph-map block 2 extended with the 8 new
  artist-domain models (artist_engagement, artist_contact, genre,
  stage, stage_day, performance, advance_section, advance_submission).
  Block 1 'artist' alias was already wired via PurposeRegistry.
- 5 form-builder backfill tests bumped --step rollback counts by +10
  to account for the 10 new May 8 migrations sitting at HEAD between
  the test's calibration point and current head.
- phpstan-baseline.neon regenerated (1631 entries) — all errors are
  same patterns existing baselined code already exhibits
  (Factory generic typing, Model property docblock gaps). Tracked
  systematically under TECH-LARASTAN-* in BACKLOG.

Tests: 1646 passing (was 1624 pre-Session-1 → +22 net, no losses).
Larastan: 0 errors over baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:15:38 +02:00

253 lines
9.9 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\ConditionalLogic;
use App\Models\FormBuilder\FormSchema;
use App\Models\Organisation;
use App\Support\Json\JsonCanonicalizer;
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 just the WS-5c backfill migration, seeds pre-WS-5c JSON into
* `form_fields.conditional_logic`, then migrates forward and backward,
* asserting:
*
* - Forward: relational rows mirror the tree shape (groups + conditions
* + parent links + sort_order).
* - Rollback: JSON reconstructed byte-accurate for the ARCH §8 shape.
*
* Cross-field cycle detection does NOT run on backfill — pre-WS-5c data
* is assumed acyclic (the JSON-era check enforced on save). Post-backfill
* the service enforces on every write.
*/
final class ConditionalLogicBackfillTest 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_backfill_builds_nested_tree_from_legacy_json(): void
{
// Roll back the WS-5d drop-options-cols + backfill-options +
// create-options + WS-5c drop-cl-col + WS-5c backfill-cl
// migrations to land in the conditional-logic JSON-era state with
// no relational form_field_options table yet.
$this->artisan('migrate:rollback', ['--step' => 21])->assertSuccessful();
$this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic'));
$fieldId = $this->seedFieldWithJson([
'show_when' => [
'all' => [
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
[
'any' => [
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'],
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'BE'],
],
],
['field_slug' => 'status', 'operator' => 'empty'],
],
],
]);
$this->artisan('migrate')->assertSuccessful();
// Root group: `all`, no parent, 2 conditions + 1 subgroup.
$rootGroup = DB::table('form_field_conditional_logic_groups')
->where('form_field_id', $fieldId)
->whereNull('parent_group_id')
->first();
$this->assertNotNull($rootGroup);
$this->assertSame('all', $rootGroup->operator);
$conditions = DB::table('form_field_conditional_logic_conditions')
->where('group_id', $rootGroup->id)
->orderBy('sort_order')
->get();
$this->assertCount(2, $conditions);
$this->assertSame('gate', $conditions[0]->field_slug);
$this->assertSame('equals', $conditions[0]->comparison_operator);
$this->assertSame('yes', json_decode((string) $conditions[0]->value, true));
$this->assertSame('status', $conditions[1]->field_slug);
$this->assertSame('empty', $conditions[1]->comparison_operator);
$this->assertNull($conditions[1]->value, 'valueless operator stores null');
// Subgroup: `any`, 2 conditions.
$subGroup = DB::table('form_field_conditional_logic_groups')
->where('parent_group_id', $rootGroup->id)
->first();
$this->assertNotNull($subGroup);
$this->assertSame('any', $subGroup->operator);
$subConditions = DB::table('form_field_conditional_logic_conditions')
->where('group_id', $subGroup->id)
->orderBy('sort_order')
->get();
$this->assertCount(2, $subConditions);
$this->assertSame('region', $subConditions[0]->field_slug);
$this->assertSame('NL', json_decode((string) $subConditions[0]->value, true));
}
public function test_rollback_reconstructs_canonical_json(): void
{
// Starting state: fully migrated. The conditional_logic column is
// already gone (WS-5c commit 3 drop). Seed relational rows bypassing
// the service, then roll back two steps — drop-column reverses first
// (column re-appears), then the backfill's `down()` reads relational
// rows and writes the JSON back.
$org = Organisation::factory()->create();
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
$fieldId = (string) Str::ulid();
DB::table('form_fields')->insert([
'id' => $fieldId,
'form_schema_id' => $schema->id,
'field_type' => 'TEXT',
'slug' => 'subject',
'label' => 'Subject',
'value_storage_hint' => 'json',
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$rootId = (string) Str::ulid();
DB::table('form_field_conditional_logic_groups')->insert([
'id' => $rootId,
'form_field_id' => $fieldId,
'parent_group_id' => null,
'operator' => 'all',
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('form_field_conditional_logic_conditions')->insert([
'id' => (string) Str::ulid(),
'group_id' => $rootId,
'field_slug' => 'gate',
'comparison_operator' => 'equals',
'value' => json_encode('yes'),
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$subGroupId = (string) Str::ulid();
DB::table('form_field_conditional_logic_groups')->insert([
'id' => $subGroupId,
'form_field_id' => $fieldId,
'parent_group_id' => $rootId,
'operator' => 'any',
'sort_order' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('form_field_conditional_logic_conditions')->insert([
'id' => (string) Str::ulid(),
'group_id' => $subGroupId,
'field_slug' => 'region',
'comparison_operator' => 'equals',
'value' => json_encode('NL'),
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
// Roll back only the backfill migration — writes the JSON back.
$this->artisan('migrate:rollback', ['--step' => 21])->assertSuccessful();
$reconstructed = DB::table('form_fields')
->where('id', $fieldId)
->value('conditional_logic');
$this->assertNotNull($reconstructed);
$json = json_decode((string) $reconstructed, true);
// RFC-WS-6 session 2.7: migration's down() reconstructs JSON via
// raw DB writer (not the canonicalizing service). Compare on
// canonical form so the assertion is engine-agnostic.
$this->assertSame(
JsonCanonicalizer::encode([
'show_when' => [
'all' => [
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
[
'any' => [
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'],
],
],
],
],
]),
JsonCanonicalizer::encode($json),
);
// Relational tables cleared after reconstruction.
$this->assertSame(0, DB::table('form_field_conditional_logic_groups')->count());
$this->assertSame(0, DB::table('form_field_conditional_logic_conditions')->count());
}
public function test_unknown_top_level_key_fails_migration(): void
{
$this->artisan('migrate:rollback', ['--step' => 21])->assertSuccessful();
$this->seedFieldWithJson([
'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]],
]);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/hide_when/');
$this->artisan('migrate');
}
public function test_unknown_comparison_operator_fails_migration(): void
{
$this->artisan('migrate:rollback', ['--step' => 21])->assertSuccessful();
$this->seedFieldWithJson([
'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]],
]);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/matches_regex/');
$this->artisan('migrate');
}
/** @param array<string, mixed> $json */
private function seedFieldWithJson(array $json): 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' => 'TEXT',
'slug' => 'f-'.Str::lower(Str::random(4)),
'label' => 'field',
'value_storage_hint' => 'json',
'sort_order' => 0,
'conditional_logic' => json_encode($json),
'created_at' => now(),
'updated_at' => now(),
]);
return $id;
}
}