not->toBeFalse(); expect($managerSource)->toContain('public function queueDiscovery'); expect($managerSource)->toContain('public function dispatchRelayStatus'); expect($managerSource)->toContain('public function dispatchRelayStatusLocalOnly'); expect($managerSource)->toContain('public function dispatchRelaySwitch'); expect($managerSource)->toContain('public function dispatchRelaySwitchLocalOnly'); expect($managerSource)->toContain('public function dispatchRelaySwitchWithTimer'); expect($managerSource)->toContain('public function dispatchRelaySwitchLocalOnlyWithTimer'); expect($managerSource)->toContain('public function queueRelayStatusBatch'); expect($managerSource)->toContain('public function queueRelaySwitchBatch'); expect($managerSource)->toContain('public function relayBatchStatus'); expect($managerSource)->toContain('private function createCommandJob'); expect($managerSource)->toContain('private function queueRelayBatch'); expect($managerSource)->toContain("'GET_RELAY_STATUS'"); expect($managerSource)->toContain("'SET_RELAY_STATE'"); expect($managerSource)->toContain("'relayId' => \$logicalRelayId"); expect($managerSource)->toContain("'deviceId' => \$binding['device_id']"); expect($managerSource)->toContain("'localIp' => \$binding['local_ip']"); expect($managerSource)->toContain("'channel' => (int)\$binding['channel']"); expect($managerSource)->toContain("'on' => \$on"); expect($managerSource)->toContain("'require_fast_path' => \$requireFastLocalPath"); expect($managerSource)->toContain('private function resolveRelayBindingDeviceGeneration'); expect($managerSource)->toContain('private function normalizeDeviceCapabilities'); expect($managerSource)->toContain("\$request['device_generation'] = \$deviceGeneration;"); expect($managerSource)->toContain("\$request['toggle_after'] = \$toggleAfterSeconds;"); expect($managerSource)->toContain('private function expireTimedOutRelayStatusCommandJobs'); expect($managerSource)->toContain("AND command_type = 'GET_RELAY_STATUS'"); expect($managerSource)->toContain("'Edge gateway command timed out', null, 'TIMED_OUT'"); expect($managerSource)->toContain('private function normalizeCommandFailureStatus'); expect($managerSource)->toContain('private function isCommandTimeoutError'); expect($managerSource)->toContain("'batch_id' => \$batchId"); expect($managerSource)->toContain("'batch_dedupe_key' => \$batchDedupeKey"); expect($managerSource)->toContain("'idempotency_key' => \$batchId . ':' . (string)\$command['target']"); expect($managerSource)->toContain("'no_auto_retry' => \$isGatePulse"); expect($managerSource)->toContain('private function commandDisallowsAutomaticRetry'); expect($managerSource)->toContain('private function requeueCommandForApiPolling'); expect($managerSource)->toContain("'preferred_channel' => self::DELIVERY_CHANNEL_API"); expect($managerSource)->toContain("'fallback_reason' => 'broker_dispatch_failed'"); expect($managerSource)->toContain("\$this->requeueCommandForApiPolling(\$job, \$exception->getMessage());"); expect($managerSource)->toContain('self::COMMAND_POLL_TIMEOUT_SECONDS + 5'); expect($managerSource)->toContain('return $this->waitForCommandResult((int)$job->id, $waitTimeoutSeconds);'); expect($managerSource)->toContain('private function buildRelayBatchDedupeKey'); expect($managerSource)->toContain('private function findRecentRelayBatchByDedupeKey'); expect($managerSource)->toContain('private function enforceRelayCommandRateLimit'); expect($managerSource)->toContain('RELAY_GATE_RATE_LIMIT_PER_MINUTE'); expect($managerSource)->toContain("JSON_EXTRACT(delivery_json, \\'$.no_auto_retry\\')"); expect($managerSource)->toContain('not retrying non-idempotent gate pulse'); expect($managerSource)->toContain('UNKNOWN_OUTCOME'); expect($managerSource)->toContain('recent_unknown_gate_outcomes'); expect($managerSource)->toContain('recent_relay_failure_rate'); expect($managerSource)->toContain('local_transport_override'); expect($managerSource)->toContain('private function resolveRelayBindingLocalIp'); expect($managerSource)->toContain('private function findShellyCloudRelayLocalIp'); expect($managerSource)->toContain('private function backfillRelayBindingLocalIpFromInventory'); expect($managerSource)->toContain('$bindingObject->local_ip->set($localIp);'); expect($managerSource)->toContain("'local_ip' => \$localIp"); expect($managerSource)->toContain('(new shelly_relay_inventory())->listRelayOptions(true)'); expect($managerSource)->toContain('public function buildUpdateOperationRequest'); expect($managerSource)->toContain('public function rotateGatewayCredentials'); expect($operationServiceSource)->toContain('public function queueOperation'); expect($operationServiceSource)->toContain('public function cancelOperation'); expect($operationServiceSource)->toContain('public function claimNextOperation'); expect($operationServiceSource)->toContain('public function completeAgentOperation'); expect($operationServiceSource)->toContain("public const STATUS_CANCEL_REQUESTED = 'CANCEL_REQUESTED';"); expect($operationServiceSource)->toContain("public const STATUS_CANCELLED = 'CANCELLED';"); expect($operationServiceSource)->toContain("public const ERROR_CANCELLED = 'EDGE_GATEWAY_CANCELLED';"); expect($operationServiceSource)->toContain('public const OPERATION_LEASE_SECONDS = 45;'); expect($operationServiceSource)->toContain('private function refreshOperationLease'); expect($operationServiceSource)->toContain('agent_instance_id'); expect($operationServiceSource)->toContain('lease_expires_at'); expect($operationServiceSource)->toContain("'operation_type' => \$type"); expect($managerSource)->toContain("command_type"); }); it('resolves relay bindings without requiring a primary department gateway first', function (): void { $managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php')); expect($managerSource)->not->toBeFalse(); preg_match( '/public function resolveRelayBinding\(int \$departmentId, string \$logicalRelayId\): array\s*\{(?P.*?)\n\x20{4}\x7d\n\n\x20{4}\/\*\*/s', (string)$managerSource, $matches ); expect($matches)->toHaveKey('body'); $body = (string)$matches['body']; expect($body)->toContain("'department_id' => \$departmentId") ->and($body)->toContain("'relay_id' => \$logicalRelayId") ->and($body)->not->toContain('getPrimaryGatewayForDepartment') ->and($body)->toContain('(new edge_gateways_o())->select((int)$row[\'gateway_id\'])') ->and($body)->toContain('No active edge gateway found for relay') ->and($body)->toContain('statusPriority') ->and($body)->toContain('heartbeatTimestamp'); }); it('reactivates soft-deleted relay bindings before inserting replacements', function (): void { $managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php')); expect($managerSource)->not->toBeFalse(); preg_match( '/public function setRelayBindings\(int \$gatewayId, array \$bindings, \?int \$userId = null\): array\s*\{(?P.*?)\n\x20{4}\x7d\n\n\x20{4}\/\*\*/s', (string)$managerSource, $matches ); expect($matches)->toHaveKey('body'); $body = (string)$matches['body']; expect($body)->toContain("'gateway_id' => \$gatewayId") ->and($body)->toContain("'relay_id' => \$relayId") ->and($body)->toContain('$bindingObject->deleted_at->set(null);') ->and($body)->not->toContain("'deleted_at' => null,\n ], ['id']);"); }); it('loads relay command helpers on the manager and gateway operations on the dedicated service', function (): void { $reflection = new ReflectionClass(edge_gateway_manager::class); $operationServiceReflection = new ReflectionClass(\classes\edge_gateway_operation_service::class); expect($reflection->hasMethod('queueDiscovery'))->toBeTrue(); expect($reflection->hasMethod('pollCommand'))->toBeTrue(); expect($reflection->hasMethod('submitCommandResult'))->toBeTrue(); expect($reflection->hasMethod('dispatchRelayStatus'))->toBeTrue(); expect($reflection->hasMethod('dispatchRelayStatusLocalOnly'))->toBeTrue(); expect($reflection->hasMethod('dispatchRelaySwitch'))->toBeTrue(); expect($reflection->hasMethod('dispatchRelaySwitchLocalOnly'))->toBeTrue(); expect($reflection->hasMethod('dispatchRelaySwitchWithTimer'))->toBeTrue(); expect($reflection->hasMethod('dispatchRelaySwitchLocalOnlyWithTimer'))->toBeTrue(); expect($reflection->hasMethod('queueRelayStatusBatch'))->toBeTrue(); expect($reflection->hasMethod('queueRelaySwitchBatch'))->toBeTrue(); expect($reflection->hasMethod('relayBatchStatus'))->toBeTrue(); expect($reflection->hasMethod('claimNextCommandJob'))->toBeTrue(); expect($reflection->getMethod('claimNextCommandJob')->isPrivate())->toBeTrue(); expect($reflection->hasMethod('syncDeviceInventory'))->toBeTrue(); expect($reflection->getMethod('syncDeviceInventory')->isPrivate())->toBeTrue(); expect($reflection->hasMethod('resolveRelayBindingDeviceGeneration'))->toBeTrue(); expect($reflection->getMethod('resolveRelayBindingDeviceGeneration')->isPrivate())->toBeTrue(); expect($reflection->hasMethod('normalizeDeviceCapabilities'))->toBeTrue(); expect($reflection->getMethod('normalizeDeviceCapabilities')->isPrivate())->toBeTrue(); expect($reflection->hasMethod('resolveRelayBindingLocalIp'))->toBeTrue(); expect($reflection->getMethod('resolveRelayBindingLocalIp')->isPrivate())->toBeTrue(); expect($reflection->hasMethod('backfillRelayBindingLocalIpFromInventory'))->toBeTrue(); expect($reflection->getMethod('backfillRelayBindingLocalIpFromInventory')->isPrivate())->toBeTrue(); expect($reflection->hasMethod('syncGatewayInventory'))->toBeTrue(); expect($operationServiceReflection->hasMethod('queueOperation'))->toBeTrue(); expect($operationServiceReflection->hasMethod('cancelOperation'))->toBeTrue(); expect($operationServiceReflection->hasMethod('listOperations'))->toBeTrue(); expect($operationServiceReflection->hasMethod('claimNextOperation'))->toBeTrue(); expect($operationServiceReflection->hasMethod('appendAgentOperationEvent'))->toBeTrue(); expect($operationServiceReflection->hasMethod('completeAgentOperation'))->toBeTrue(); });