Files
crewli/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php
bert.hausmans b47e096a55 feat(form-builder): retry history table + integration (WS-6)
Per-attempt retry history (timestamp, user, outcome, exception detail
if failed) replaces the counter-only retry_count tracking.

Changes:

- New `form_submission_action_failure_retry_attempts` table (cascade on
  parent delete, nullOnDelete on user). Explicit short FK names
  (`fsafra_failure_fk`, `fsafra_user_fk`) — auto-generated names exceed
  MySQL's 64-char identifier limit.
- New FormSubmissionActionFailureRetryAttempt model + factory +
  succeeded() state.
- Parent FormSubmissionActionFailure gets retryAttempts() HasMany
  relation (latest('attempted_at')).
- New FormFailureRetryService centralises the retry-flow logic. Both
  the API controller and the artisan command delegate to it. Service
  writes a retry_attempt record per attempt; parent's retry_count
  stays as denormalised cache for index-view performance.
- Successful retry: attempt(succeeded) + parent.retry_count++ +
  parent.resolved_at + parent.resolved_by_user_id + parent.resolved_note
  ("Geslaagde retry door {actor.name}" or "Geslaagde retry
  (geautomatiseerd)" for command-line invocation without an actor).
- Failed retry: attempt(failed) with NEW exception details +
  parent.retry_count++. Parent's exception_class/_message stay
  audit-immutable — they represent the FIRST failure.
- canBeRetried() now correctly checks both resolved_at AND
  dismissed_at (sessie 2's open question Q2 closure).
- New FailureNotRetriableException (controller → 422) and
  ParentSubmissionGoneException (controller → 410) for cleaner
  flow control.

12 new tests:
- FormSubmissionActionFailureRetryAttemptTest (5 unit tests)
- RetryFlowProducesRetryAttemptsTest (7 integration tests covering
  succeeded path, failed path, resolved/dismissed blocking,
  multiple-retries chronological ordering, canBeRetried truth tables)

Pre-existing tests touched:
- FormSubmissionActionFailureTest::test_can_be_retried_only_for_open_state
  — updated to reflect Q2 closure (resolved now blocks too).
- Ws6FoundationMigrationTest::test_down_methods_clean_up_columns_and_table
  — child table must drop before parent (FK constraint).
- 5 backfill test step-counts bumped +1 (new migration sits at top).

SCHEMA.md → v2.9. Schema dump regenerated.

Refs: RFC-WS-6.md §3 Q5 addendum, sessie 2 Q2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:19 +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' => 9])->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' => 9])->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' => 9])->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' => 9])->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;
}
}