Closes the four production gaps that emerged from sessie 3b's admin UI.
What we ship here is final: no further rework planned before production.
Backend
- IndexFailuresRequest validates state/search/failed_at_from/failed_at_to/
listener_class. orgIndex + platformIndex apply them via a single
applyIndexFilters() helper. Search runs case-insensitive `LIKE` on
exception_message; SQL wildcards in user input are escaped.
- New /kpis aggregate endpoint per scope (orgKpis, platformKpis) returns
open / resolved_30d / dismissed_30d / total_submissions in O(1) COUNTs.
Replaces sessie 3b's client-side bucketing of an oversized list.
- Resource expansion: organisation_name, form_schema_label,
resolved_by_user_name, dismissed_by_user_name, exception_trace,
retry_history[]. Eager-loading via indexEagerLoads()/detailEagerLoads()
prevents N+1 (verified by query-count assertion in test).
- New 2026_04_28_181000 migration adds exception_trace (longtext nullable)
to form_submission_action_failures. ApplyBindingsOnFormSubmit listener
now captures $e->getTraceAsString() at failure time.
- New FormSubmissionActionFailureRetryAttemptResource exposes per-attempt
data (timestamp, actor name, outcome, exception details) inside
retry_history[]. Index payloads omit the field via whenLoaded() to keep
list responses lean.
Frontend (apps/app)
- Types updated to mirror the expanded resource shape and the new KPI
endpoint contract. FormFailuresKpis is now { open, resolved_30d,
dismissed_30d, total_submissions } (server-aggregate).
- useFormFailures composable forwards all 5 server filters via
buildIndexParams() (strips empty/whitespace). useFormFailuresKpis hits
the dedicated /kpis endpoint per scope.
- FormFailuresTable replaces client-side bucketing with server-side
filtering, adds listener_class + date-range filter inputs, and renames
the 4th KPI tile to "Submissions" (was "Totaal").
- FormFailureDetail renders organisation_name + form_schema_label in the
header, surfaces an expandable stack-trace card, names the resolved/
dismissed actor in the timeline, and replaces the "v1 placeholder"
retry-history card with a full per-attempt timeline.
ESLint config gap (apps/app)
- New .eslintrc.cjs adapted from the Vuexy reference, minus Vuexy-internal
rules. `pnpm lint` now runs successfully (was previously broken — the
package.json script referenced a missing config). The 80 baseline
violations across the codebase are pre-existing and out of scope for
this session.
Tests + gates
- 24 new backend tests across filter, kpis, and resource-shape suites.
Backend: 1462 → 1486 passing, 0 → 0 failing. Larastan clean. Rector
dry-run unchanged at 354 (pre-Task-1 baseline from f18b55b).
- 3 new vitest tests in apps/app (filter wiring, KPI endpoint, KPI tile
values from /kpis). Vitest: 38 → 41 passing. tsc clean. Portal
unchanged (113 vitest, tsc clean).
- 5 backfill rollback tests bumped --step counts +1 for the new migration.
- Ws6FoundationMigrationTest down/up chain now includes exception_trace
before the parent table is restored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
253 lines
9.9 KiB
PHP
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' => 10])->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' => 10])->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' => 10])->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' => 10])->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;
|
|
}
|
|
}
|