compare( $canonicalType, [...$payload, 'modifiedDateTime' => '2026-06-28T10:00:00Z'], [...$payload, 'modifiedDateTime' => '2026-06-28T11:00:00Z'], ); expect($result['changed'])->toBeFalse() ->and($result['classification'])->toBe('unchanged') ->and(collect($result['changes'])->pluck('classification'))->toContain('ignored_volatile'); })->with([ 'transport rule' => ['transportRule', ['DisplayName' => 'Rule', 'Enabled' => true]], 'meeting policy' => ['meetingPolicy', ['DisplayName' => 'Meeting', 'AllowCloudRecording' => true]], ]); it('Spec422 detects material Exchange and Teams changes with bounded importance', function (string $canonicalType, array $before, array $after, string $field, string $importance, string $classification = 'changed'): void { $result = app(ExchangeTeamsCoverageComparator::class)->compare($canonicalType, $before, $after); $change = collect($result['changes'])->firstWhere('field', $field); expect($result['changed'])->toBeTrue() ->and($result['classification'])->toBe('changed') ->and($change)->not->toBeNull() ->and($change['classification'])->toBe($classification) ->and($change['importance'])->toBe($importance); })->with([ 'transport enabled' => [ 'transportRule', ['DisplayName' => 'Rule', 'Enabled' => true], ['DisplayName' => 'Rule', 'Enabled' => false], 'enabled_state', 'critical', ], 'accepted domain default' => [ 'acceptedDomain', ['DomainName' => 'contoso.com', 'IsDefault' => false], ['DomainName' => 'contoso.com', 'IsDefault' => true], 'is_default', 'critical', ], 'app blocked list' => [ 'appPermissionPolicy', ['DisplayName' => 'Policy', 'BlockAppList' => []], ['DisplayName' => 'Policy', 'BlockAppList' => [['DisplayName' => 'Consumer App', 'AppId' => 'consumer-app']]], 'blocked_apps', 'critical', 'added', ], 'meeting transcription' => [ 'meetingPolicy', ['DisplayName' => 'Meeting', 'AllowTranscription' => false], ['DisplayName' => 'Meeting', 'AllowTranscription' => true], 'recording_transcription.allow_transcription', 'critical', ], ]); it('Spec422 keeps array ordering stable and handles null or empty values explicitly', function (): void { $ordered = app(ExchangeTeamsCoverageComparator::class)->compare('appPermissionPolicy', [ 'DisplayName' => 'Policy', 'AllowAppList' => [ ['DisplayName' => 'Planner', 'AppId' => 'planner-app'], ['DisplayName' => 'Bookings', 'AppId' => 'bookings-app'], ], ], [ 'DisplayName' => 'Policy', 'AllowAppList' => [ ['DisplayName' => 'Bookings', 'AppId' => 'bookings-app'], ['DisplayName' => 'Planner', 'AppId' => 'planner-app'], ], ]); $added = app(ExchangeTeamsCoverageComparator::class)->compare('meetingPolicy', [ 'DisplayName' => 'Meeting', 'AllowCloudRecording' => null, ], [ 'DisplayName' => 'Meeting', 'AllowCloudRecording' => true, ]); expect($ordered['changed'])->toBeFalse() ->and($added['changed'])->toBeTrue() ->and(collect($added['changes'])->firstWhere('field', 'recording_transcription.allow_cloud_recording')['classification'])->toBe('added'); }); it('Spec422 records redacted and unsupported Exchange/Teams fields as non-material diagnostics', function (): void { $result = app(ExchangeTeamsCoverageComparator::class)->compare( 'transportRule', ['DisplayName' => 'Rule', 'Enabled' => true], ['DisplayName' => 'Rule', 'Enabled' => true, 'clientSecret' => 'spec422-comparator-secret'], ); expect($result['changed'])->toBeFalse() ->and(collect($result['changes'])->pluck('classification'))->toContain('redacted', 'unsupported_field') ->and(json_encode($result, JSON_THROW_ON_ERROR))->not->toContain('spec422-comparator-secret'); });