Files
api/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php
T
Jeppe B a7181a4ab2 Fix edge gateway relay binding reactivation
Reactivate existing relay binding rows when a gateway/relay pair is re-added after soft deletion, avoiding duplicate uniq_edge_gateway_binding inserts. Add regression coverage for the reactivation path.
2026-07-08 18:46:33 +02:00

162 lines
11 KiB
PHP

<?php
app_require('classes/edge_gateway_manager.php');
use classes\edge_gateway_manager;
it('keeps relay dispatch and discovery queueing on the edge gateway manager', function (): void {
$managerSource = file_get_contents(app_path('classes/edge_gateway_manager.php'));
$operationServiceSource = file_get_contents(app_path('classes/edge_gateway_operation_service.php'));
expect($managerSource)->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<body>.*?)\n \}\n\n \/\*\*/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<body>.*?)\n \}\n\n \/\*\*/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();
});