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>
170 lines
6.6 KiB
PHP
170 lines
6.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\FormBuilder\Options;
|
|
|
|
use App\Enums\FormBuilder\FormFieldType;
|
|
use App\Models\FormBuilder\FormField;
|
|
use App\Models\FormBuilder\FormSchema;
|
|
use App\Models\Organisation;
|
|
use App\Services\FormBuilder\FormSubmissionService;
|
|
use App\Support\Json\JsonCanonicalizer;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Snapshot embedding parity (FormSubmissionService::buildSnapshot) +
|
|
* FormRequest strict validator coverage for the spec-array shape. Both
|
|
* land at WS-5d commit 3.
|
|
*/
|
|
final class FormFieldOptionsSnapshotAndStrictRequestTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_submission_snapshot_embeds_rich_shape_options(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create([
|
|
'organisation_id' => $org->id,
|
|
'snapshot_mode' => 'on_submit',
|
|
'is_published' => true,
|
|
'public_token' => (string) \Illuminate\Support\Str::ulid(),
|
|
]);
|
|
FormField::factory()
|
|
->withOptions(['XS', 'S', 'M'])
|
|
->create([
|
|
'form_schema_id' => $schema->id,
|
|
'field_type' => FormFieldType::SELECT->value,
|
|
'slug' => 'shirtmaat',
|
|
'label' => 'Shirtmaat',
|
|
]);
|
|
|
|
$service = app(FormSubmissionService::class);
|
|
$draft = $service->createDraft($schema, null, null, []);
|
|
$service->submit($draft, null);
|
|
|
|
$snapshot = $draft->fresh()->schema_snapshot;
|
|
$this->assertIsArray($snapshot);
|
|
$field = collect($snapshot['fields'])->firstWhere('slug', 'shirtmaat');
|
|
// RFC-WS-6 session 2.7: schema_snapshot is canonicalized at write,
|
|
// so byte equality holds when both sides go through
|
|
// JsonCanonicalizer::encode.
|
|
$this->assertSame(
|
|
JsonCanonicalizer::encode([
|
|
['value' => 'XS', 'label' => 'XS', 'sort_order' => 0],
|
|
['value' => 'S', 'label' => 'S', 'sort_order' => 1],
|
|
['value' => 'M', 'label' => 'M', 'sort_order' => 2],
|
|
]),
|
|
JsonCanonicalizer::encode($field['options']),
|
|
);
|
|
}
|
|
|
|
public function test_submission_snapshot_does_not_emit_locale_options_in_field_translations(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->create([
|
|
'organisation_id' => $org->id,
|
|
'snapshot_mode' => 'on_submit',
|
|
'is_published' => true,
|
|
'public_token' => (string) \Illuminate\Support\Str::ulid(),
|
|
]);
|
|
FormField::factory()
|
|
->withOptions([
|
|
['value' => 'a', 'label' => 'A', 'sort_order' => 0, 'translations' => ['nl' => 'AA']],
|
|
])
|
|
->create([
|
|
'form_schema_id' => $schema->id,
|
|
'field_type' => FormFieldType::SELECT->value,
|
|
'slug' => 'choice',
|
|
'label' => 'Choice',
|
|
// Per-locale label kept; the legacy {locale}.options[] is
|
|
// dead post-WS-5d.
|
|
'translations' => ['nl' => ['label' => 'Keuze']],
|
|
]);
|
|
|
|
$service = app(FormSubmissionService::class);
|
|
$draft = $service->createDraft($schema, null, null, []);
|
|
$service->submit($draft, null);
|
|
|
|
$snapshot = $draft->fresh()->schema_snapshot;
|
|
$field = collect($snapshot['fields'])->firstWhere('slug', 'choice');
|
|
if (is_array($field['translations'] ?? null)) {
|
|
foreach ($field['translations'] as $locale => $bag) {
|
|
$this->assertArrayNotHasKey('options', is_array($bag) ? $bag : [], "locale {$locale} kept legacy options key");
|
|
}
|
|
}
|
|
}
|
|
|
|
public function test_form_field_request_rejects_missing_value_in_spec(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$admin = \App\Models\User::factory()->create();
|
|
$org->users()->attach($admin, ['role' => 'org_admin']);
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
\Laravel\Sanctum\Sanctum::actingAs($admin);
|
|
|
|
$response = $this->postJson("/api/v1/organisations/{$org->id}/forms/schemas/{$schema->id}/fields", [
|
|
'field_type' => FormFieldType::SELECT->value,
|
|
'slug' => 'choice',
|
|
'label' => 'Choice',
|
|
'options' => [
|
|
['label' => 'A', 'sort_order' => 0],
|
|
],
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors(['options.0.value']);
|
|
}
|
|
|
|
public function test_form_field_request_rejects_duplicate_values_in_spec(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$admin = \App\Models\User::factory()->create();
|
|
$org->users()->attach($admin, ['role' => 'org_admin']);
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
\Laravel\Sanctum\Sanctum::actingAs($admin);
|
|
|
|
$response = $this->postJson("/api/v1/organisations/{$org->id}/forms/schemas/{$schema->id}/fields", [
|
|
'field_type' => FormFieldType::SELECT->value,
|
|
'slug' => 'choice',
|
|
'label' => 'Choice',
|
|
'options' => [
|
|
['value' => 'dup', 'label' => 'A', 'sort_order' => 0],
|
|
['value' => 'dup', 'label' => 'B', 'sort_order' => 1],
|
|
],
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors(['options']);
|
|
}
|
|
|
|
public function test_form_field_request_accepts_valid_spec(): void
|
|
{
|
|
$org = Organisation::factory()->create();
|
|
$admin = \App\Models\User::factory()->create();
|
|
$org->users()->attach($admin, ['role' => 'org_admin']);
|
|
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
|
|
\Laravel\Sanctum\Sanctum::actingAs($admin);
|
|
|
|
$response = $this->postJson("/api/v1/organisations/{$org->id}/forms/schemas/{$schema->id}/fields", [
|
|
'field_type' => FormFieldType::SELECT->value,
|
|
'slug' => 'choice',
|
|
'label' => 'Choice',
|
|
'options' => [
|
|
['value' => 'red', 'label' => 'Red', 'sort_order' => 0],
|
|
['value' => 'green', 'label' => 'Green', 'sort_order' => 1, 'translations' => ['nl' => 'Groen']],
|
|
],
|
|
]);
|
|
|
|
$response->assertCreated();
|
|
$this->assertSame(
|
|
[
|
|
['value' => 'red', 'label' => 'Red', 'sort_order' => 0],
|
|
['value' => 'green', 'label' => 'Green', 'sort_order' => 1, 'translations' => ['nl' => 'Groen']],
|
|
],
|
|
$response->json('data.options'),
|
|
);
|
|
}
|
|
}
|