Files
crewli/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php
bert.hausmans a791a276fa fix(form-builder): canonicalize JSON for byte-stable storage (WS-6)
MySQL 8.0 JSON columns may reorder associative-array keys on
round-trip. For audit-immutable values (schema snapshots, webhook
payloads, activity log diffs), this is corrupting: re-emits produce
different byte sequences for the same logical content.

Introduced JsonCanonicalizer (recursive ksort on associative arrays;
numeric-indexed lists preserve order) and applied at every writer
site that produces byte-stable JSON:

- FormSubmissionService: canonicalize the schema_snapshot array
  before storage (audit-immutable per ARCH §4.3, RFC-WS-6 v1.1).
- FormField::logFieldChange / FormSchema::logSchemaChange: canonicalize
  activity-log properties before withProperties() so old/new diffs
  read back byte-stable.
- BindingActivityLogger: canonicalize both the pass-level and
  per-binding activity properties.
- FormWebhookDispatcher: canonicalize payload_snapshot before
  storage (delivery-time HMAC re-encodes the same canonical bytes).
- DeliverFormWebhookJob: switched json_encode to
  JsonCanonicalizer::encode for the HMAC-signed body, so the
  signature is byte-stable across re-deliveries and reproducible by
  receivers from the same logical payload.

Sites NOT canonicalized (deliberate):
- form_schemas.settings — opaque UI config; key order has no
  semantic meaning, no byte-stability requirement.
- form_schemas.translations / form_fields.translations — read by
  display layer; key order doesn't matter.
- form_templates.schema_snapshot — user-supplied input via store/
  update; user is the source of truth, not audit-immutable in the
  same way as form_submissions.schema_snapshot.

Reverted the 7 assertEquals workarounds from session 2.6:
- 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

Each now uses assertSame on JsonCanonicalizer::encode of both sides —
byte-stable comparison meaningful regardless of MySQL JSON storage
behavior.

New regression test SchemaSnapshotByteStableAcrossReemitsTest
exercises the contract end-to-end: complex schema with bindings,
validation rules, options, conditional logic, submitted; reads
schema_snapshot via three roads (Eloquent cast, fresh model, raw
bytes) and asserts the canonical encode is identical.

ARCH-FORM-BUILDER.md §4.6.1 gets a "Byte-stability" sub-section
explaining what's canonicalized and why.

Test count: 1388 → 1400 (+11 JsonCanonicalizer unit, +1 snapshot
regression). Larastan clean. Rector dry-run unchanged at 355.

Refs: WS-6 session 2.6 deviation #4 cleanup, RFC-WS-6 v1.1

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