ARCH §8.6 specifies a dual-event contract on logic changes — a
`field.updated` row carrying old/new diffs of the reconstructed JSON
shape, plus a semantic `field.conditional_logic_replaced` row from
inside `replaceLogic()`. The semantic event is already pinned by
`FormFieldConditionalLogicServiceTest`. The diff payload contract was
documented but unasserted.
Two new tests:
- `test_field_updated_activity_log_contains_conditional_logic_diff_when_tree_changes`
Pins old/new payload shapes via byte-equal `json_encode` comparison
(mirrors ConditionalLogicSnapshotAndResourceParityTest's
associative-array key-order trap). Both rows share the same
causer_id.
- `test_field_updated_without_logic_change_does_not_emit_conditional_logic_diff`
Pins the negative: bare label-only updates must NOT carry a
`conditional_logic` key in the field.updated payload, and must NOT
emit a semantic `field.conditional_logic_replaced` row.
The first test passed against the original implementation; the second
required `FormFieldService::update()` to filter `conditional_logic`
out of the activity-log payload when the reconstructed shape didn't
change between pre- and post-write. Adjustment lands in this commit:
the `$before` / `$new` arrays now only carry the key when
`$currentConditionalShape !== $newConditionalShape`.
Tests: 1148 → 1150 green (3099 → 3110 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
150 lines
5.9 KiB
PHP
150 lines
5.9 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 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;
|
|
// Use json_encode comparison to avoid associative-array key-order traps —
|
|
// mirrors ConditionalLogicSnapshotAndResourceParityTest.
|
|
$this->assertSame(
|
|
json_encode($oldShape),
|
|
json_encode($properties->get('old')['conditional_logic'] ?? null),
|
|
);
|
|
$this->assertSame(
|
|
json_encode($newShape),
|
|
json_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');
|
|
}
|
|
}
|