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'); } }