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>
153 lines
6.1 KiB
PHP
153 lines
6.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\FormBuilder\ConditionalLogic;
|
|
|
|
use App\Models\FormBuilder\FormField;
|
|
use App\Models\FormBuilder\FormSchema;
|
|
use App\Models\Organisation;
|
|
use App\Models\User;
|
|
use App\Services\FormBuilder\FormFieldConditionalLogicService;
|
|
use App\Services\FormBuilder\FormFieldService;
|
|
use App\Support\Json\JsonCanonicalizer;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Spatie\Activitylog\Models\Activity;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* ARCH §8.6 dual-event contract on logic changes:
|
|
* - `field.updated` with `old.conditional_logic` / `new.conditional_logic`
|
|
* in the properties payload (shapes via `toJsonShape`).
|
|
* - `field.conditional_logic_replaced` from inside `replaceLogic()`.
|
|
*
|
|
* Both rows must share the same causer and emit only when the tree
|
|
* actually changed — bare label-only updates must NOT carry a
|
|
* `conditional_logic` key in the payload, and must NOT emit a
|
|
* `field.conditional_logic_replaced` row.
|
|
*/
|
|
final class ConditionalLogicActivityLogPayloadTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_field_updated_activity_log_contains_conditional_logic_diff_when_tree_changes(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'gate']);
|
|
FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'region']);
|
|
|
|
$field = FormField::factory()
|
|
->withConditionalLogic([
|
|
'operator' => 'all',
|
|
'children' => [
|
|
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
|
|
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'],
|
|
],
|
|
])
|
|
->create(['form_schema_id' => $schema->id, 'slug' => 'subject']);
|
|
|
|
$logicService = app(FormFieldConditionalLogicService::class);
|
|
$oldShape = $logicService->toJsonShape($field->fresh()->rootConditionalLogicGroup());
|
|
|
|
$causer = User::factory()->create();
|
|
$this->actingAs($causer);
|
|
Activity::query()->delete();
|
|
|
|
// Replace the tree with a different shape — single condition, ANY group.
|
|
app(FormFieldService::class)->update($field->fresh(), [
|
|
'conditional_logic' => [
|
|
'show_when' => [
|
|
'any' => [
|
|
['field_slug' => 'gate', 'operator' => 'not_empty'],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$newShape = $logicService->toJsonShape($field->fresh()->rootConditionalLogicGroup());
|
|
$this->assertNotEquals($oldShape, $newShape, 'precondition: tree must have changed');
|
|
|
|
$updated = Activity::query()
|
|
->where('subject_type', $field->getMorphClass())
|
|
->where('subject_id', $field->id)
|
|
->where('description', 'field.updated')
|
|
->first();
|
|
$this->assertNotNull($updated, 'field.updated row must exist');
|
|
|
|
$properties = $updated->properties;
|
|
// RFC-WS-6 session 2.7: canonicalized writes give byte-stable
|
|
// round-trip; both sides go through JsonCanonicalizer::encode so
|
|
// assertSame compares bytes regardless of MySQL key-order
|
|
// normalization on the JSON column read.
|
|
$this->assertSame(
|
|
JsonCanonicalizer::encode($oldShape),
|
|
JsonCanonicalizer::encode($properties->get('old')['conditional_logic'] ?? null),
|
|
);
|
|
$this->assertSame(
|
|
JsonCanonicalizer::encode($newShape),
|
|
JsonCanonicalizer::encode($properties->get('new')['conditional_logic'] ?? null),
|
|
);
|
|
|
|
$semantic = Activity::query()
|
|
->where('subject_type', $field->getMorphClass())
|
|
->where('subject_id', $field->id)
|
|
->where('description', 'field.conditional_logic_replaced')
|
|
->first();
|
|
$this->assertNotNull($semantic, 'semantic event must exist');
|
|
|
|
$this->assertSame((int) $causer->id, (int) $updated->causer_id);
|
|
$this->assertSame((int) $causer->id, (int) $semantic->causer_id);
|
|
}
|
|
|
|
public function test_field_updated_without_logic_change_does_not_emit_conditional_logic_diff(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'gate']);
|
|
|
|
$field = FormField::factory()
|
|
->withConditionalLogic([
|
|
'operator' => 'all',
|
|
'children' => [
|
|
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
|
|
],
|
|
])
|
|
->create(['form_schema_id' => $schema->id, 'slug' => 'subject', 'label' => 'Old label']);
|
|
|
|
$causer = User::factory()->create();
|
|
$this->actingAs($causer);
|
|
Activity::query()->delete();
|
|
|
|
app(FormFieldService::class)->update($field->fresh(), [
|
|
'label' => 'New label',
|
|
]);
|
|
|
|
$updated = Activity::query()
|
|
->where('subject_type', $field->getMorphClass())
|
|
->where('subject_id', $field->id)
|
|
->where('description', 'field.updated')
|
|
->first();
|
|
$this->assertNotNull($updated, 'field.updated row must exist for the label change');
|
|
|
|
$properties = $updated->properties;
|
|
$this->assertArrayNotHasKey(
|
|
'conditional_logic',
|
|
$properties->get('old') ?? [],
|
|
'old payload must not include conditional_logic when tree did not change',
|
|
);
|
|
$this->assertArrayNotHasKey(
|
|
'conditional_logic',
|
|
$properties->get('new') ?? [],
|
|
'new payload must not include conditional_logic when tree did not change',
|
|
);
|
|
|
|
$semantic = Activity::query()
|
|
->where('subject_type', $field->getMorphClass())
|
|
->where('subject_id', $field->id)
|
|
->where('description', 'field.conditional_logic_replaced')
|
|
->first();
|
|
$this->assertNull($semantic, 'no semantic event when tree did not change');
|
|
}
|
|
}
|