createDepartment([ 'name' => 'Edge Integration Install Department', ]); $token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Install Token'); $claimTokenId = (int)($token['claim_token_id'] ?? 0); $context['manager']->reportInstallTokenStatus((string)$token['token'], [ 'status' => 'FAILED', 'step' => 'DOWNLOAD_FAILED', 'message' => 'The installer could not download the runtime bundle.', 'diagnostics' => [ 'diag-1', 'diag-2', 'diag-3', 'diag-4', 'diag-5', 'diag-6', 'diag-7', 'diag-8', ], ]); $failedStatus = $context['manager']->getInstallTokenStatus($claimTokenId); expect($failedStatus) ->toHaveKey('status', 'FAILED') ->toHaveKey('step', 'DOWNLOAD_FAILED') ->toHaveKey('last_error', 'The installer could not download the runtime bundle.') ->and($failedStatus['diagnostics'] ?? []) ->toBeArray() ->toHaveCount(6); $claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-integration-host', 'php-agent-v1', [ 'source' => 'integration-test', ]); $gatewayId = (int)($claimed['gateway']['id'] ?? 0); $agentToken = (string)($claimed['agent_token'] ?? ''); expect($gatewayId)->toBeGreaterThan(0) ->and($agentToken)->not->toBe(''); $claimedStatus = $context['manager']->getInstallTokenStatus($claimTokenId); expect($claimedStatus) ->toHaveKey('status', 'CLAIMED') ->toHaveKey('gateway_id', $gatewayId) ->toHaveKey('last_error', null); edge_gateway_integration_set_heartbeat_age($context['mysqli'], $context['redis'], $gatewayId, edge_gateway_manager::HEARTBEAT_DEGRADED_AFTER_SECONDS + 1); $degraded = $context['manager']->getGateway($gatewayId); expect($degraded)->toHaveKey('status', 'DEGRADED'); edge_gateway_integration_set_heartbeat_age($context['mysqli'], $context['redis'], $gatewayId, edge_gateway_manager::HEARTBEAT_OFFLINE_AFTER_SECONDS + 1); $offline = $context['manager']->getGateway($gatewayId); expect($offline)->toHaveKey('status', 'OFFLINE'); $context['manager']->recordHeartbeat($gatewayId, $agentToken, [ 'status' => 'ONLINE', 'hostname' => 'edge-integration-host', 'metadata' => [ 'system_metrics' => [ 'cpu_percent' => 44, ], ], ]); $online = $context['manager']->getGateway($gatewayId); expect($online) ->toHaveKey('status', 'ONLINE') ->and($online['metadata']['system_metrics']['cpu_percent'] ?? null) ->toBe(44); } finally { $context['cleanup']->run(); } }); it('assembles tasks, logs, statistics, operations, commands, and shell lifecycle state from persisted records', function (): void { $context = edge_gateway_integration_context(); try { $department = $context['fixtures']->createDepartment([ 'name' => 'Edge Integration Runtime Department', ]); $user = $context['fixtures']->createUser([ 'display_name' => 'Edge Integration User', ]); $token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Runtime Token', (int)$user['id']); $claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-runtime-host', 'php-agent-v1'); $gatewayId = (int)($claimed['gateway']['id'] ?? 0); $agentToken = (string)($claimed['agent_token'] ?? ''); $operation = $context['operations']->queueOperation( $gatewayId, edge_gateway_operation_service::TYPE_DISCOVERY, ['inventory' => edge_gateway_integration_inventory('runtime')], (int)$user['id'] ); $operationId = (int)($operation['id'] ?? 0); expect($operationId)->toBeGreaterThan(0); $claimedOperation = $context['operations']->claimNextOperation($gatewayId, $agentToken, 0, 'integration-agent-1'); expect($claimedOperation) ->toBeArray() ->toHaveKey('status', 'IN_PROGRESS'); $context['operations']->appendAgentOperationEvent($gatewayId, $operationId, $agentToken, [ 'level' => 'INFO', 'code' => 'DISCOVERY_RUNNING', 'message' => 'Integration discovery is executing.', 'context' => [ 'progress' => 70, ], ]); $context['operations']->completeAgentOperation($gatewayId, $operationId, $agentToken, [ 'ok' => true, 'result' => [ 'inventory' => edge_gateway_integration_inventory('completed'), ], ]); $job = $context['fixtures']->createEdgeCommandJob([ 'gateway_id' => $gatewayId, 'command_type' => 'GET_RELAY_STATUS', 'request' => [ 'relayId' => 'relay-main', ], 'requested_by' => (int)$user['id'], ]); $jobId = (int)($job['id'] ?? 0); $polledCommand = $context['manager']->pollCommand($gatewayId, $agentToken, 0); expect($polledCommand) ->toBeArray() ->toHaveKey('id', $jobId) ->toHaveKey('command_type', 'GET_RELAY_STATUS'); $commandResult = $context['manager']->submitCommandResult($gatewayId, $jobId, $agentToken, true, [ 'relayId' => 'relay-main', 'online' => true, ]); expect($commandResult) ->toHaveKey('acknowledged', true) ->and($commandResult['job']['status'] ?? null) ->toBe('COMPLETED'); $context['manager']->recordBrokerPresence( $gatewayId, 'connected', 'broker-connection-1', null, ['transport' => 'ws'] ); $shellSession = $context['manager']->createShellSession( $gatewayId, (int)$user['id'], 'Integration shell session', 120, 40, '/opt/truckwash-edge-agent' ); $shellToken = (string)($shellSession['token'] ?? ''); expect($shellToken)->not->toBe(''); expect($shellSession['diagnostics']['broker_presence']['connection_id'] ?? null) ->toBe('broker-connection-1'); $validatedShell = $context['manager']->validateShellSessionToken($shellToken); expect($validatedShell)->toHaveKey('status', 'PENDING'); $openedShell = $context['manager']->markShellSessionOpened($shellToken, 'shell-connection-1'); expect($openedShell)->toHaveKey('status', 'OPEN'); $closedShell = $context['manager']->closeShellSessionByToken( $shellToken, "edge-shell-output\n", 'agent_exit', [ 'message' => 'Integration shell completed.', 'code' => 0, 'stage' => 'shell_active', ] ); expect($closedShell) ->toHaveKey('status', 'COMPLETED') ->and($closedShell['transcript'] ?? null) ->toBe("edge-shell-output\n") ->and($closedShell['metadata']['close_reason'] ?? null) ->toBe('agent_exit') ->and($closedShell['metadata']['close_stage'] ?? null) ->toBe('shell_active'); $context['manager']->appendGatewayLogEntry( $gatewayId, 'Integration log line', 'INFO', 'agent', 'BROKER', ['source' => 'integration'] ); $context['manager']->appendRelayTransportLog( (int)$department['id'], '/v2/devices/api/set/switch', ['id' => 'M-7', 'on' => true, 'toggle_after' => 3], [['id' => 'M-7', 'online' => true, 'on' => true]], 'cloud', null, [ 'module' => 'selfserve', 'reason' => 'Integration relay start', 'relay_name' => 'Roskilde Maskine', 'relay_role' => 'MACHINE', 'customer_number' => 700123, 'actor' => [ 'admin_user_id' => 42, ], ] ); $context['manager']->recordTelemetryFromBroker($gatewayId, [ 'status' => 'ONLINE', 'metadata' => [ 'system_metrics' => [ 'cpu_percent' => 17, 'memory_mb' => 256, ], ], 'inventory' => edge_gateway_integration_inventory('telemetry'), ]); $tasks = $context['manager']->buildGatewayTasksPage($gatewayId); $logs = $context['manager']->buildGatewayLogsPage($gatewayId); $statistics = $context['manager']->buildGatewayStatisticsPage($gatewayId); expect($tasks['operations'] ?? []) ->toBeArray() ->not->toBeEmpty() ->and(($tasks['operations'][0]['status'] ?? null)) ->toBe('COMPLETED'); expect($tasks['recent_commands'] ?? []) ->toBeArray() ->not->toBeEmpty() ->and(($tasks['recent_commands'][0]['status'] ?? null)) ->toBe('COMPLETED'); expect(edge_gateway_integration_messages($logs['log_entries'] ?? [])) ->toContain('Integration log line'); expect(edge_gateway_integration_messages($logs['relay_logs'] ?? [])) ->toContain('MACHINE ON Roskilde Maskine handled by cloud via CLOUD'); expect($logs['relay_logs'][0]['context']['module_responsible'] ?? null) ->toBe('selfserve') ->and($logs['relay_logs'][0]['context']['description'] ?? null) ->toBe('MACHINE ON Roskilde Maskine handled by cloud via CLOUD') ->and($logs['relay_logs'][0]['context']['relay_name'] ?? null) ->toBe('Roskilde Maskine') ->and($logs['relay_logs'][0]['context']['relay_role'] ?? null) ->toBe('MACHINE') ->and($logs['relay_logs'][0]['context']['associated']['customer_number'] ?? null) ->toBe(700123) ->and($logs['relay_logs'][0]['context']['associated']['admin_user_id'] ?? null) ->toBe(42) ->and($logs['relay_logs'][0]['context']['reason'] ?? null) ->toBe('Integration relay start') ->and($logs['relay_logs'][0]['context']['handler'] ?? null) ->toBe('cloud') ->and($logs['relay_logs'][0]['context']['signal']['request']['id'] ?? null) ->toBe('M-7') ->and($logs['relay_logs'][0]['context']['response']['on'] ?? null) ->toBeTrue(); expect(edge_gateway_integration_messages($logs['timeline'] ?? [])) ->toContain('Integration discovery is executing.'); expect(array_values(array_filter($logs['timeline'] ?? [], static fn(array $entry): bool => ($entry['type'] ?? null) === 'relay'))) ->not->toBeEmpty(); expect($logs['shell_sessions'] ?? []) ->toBeArray() ->not->toBeEmpty() ->and(($logs['shell_sessions'][0]['transcript'] ?? null)) ->toBe("edge-shell-output\n"); expect($statistics['system_metrics']['cpu_percent'] ?? null) ->toBe(17); expect($statistics['gateway']['inventory'] ?? []) ->toBeArray() ->not->toBeEmpty(); } finally { $context['cleanup']->run(); } }); it('persists gateway cutover relay bindings used by self-serve Shelly dispatch', function (): void { $context = edge_gateway_integration_context(); try { $department = $context['fixtures']->createDepartment([ 'name' => 'Edge Integration Self Serve Relay Department', ]); $user = $context['fixtures']->createUser([ 'display_name' => 'Edge Integration Relay User', ]); $token = $context['manager']->createInstallToken((int)$department['id'], 'Integration Relay Token', (int)$user['id']); $claimed = $context['manager']->claimGateway((string)$token['token'], 'edge-relay-host', 'php-agent-v1'); $gatewayId = (int)($claimed['gateway']['id'] ?? 0); $mode = $context['manager']->setDepartmentTransportMode( (int)$department['id'], edge_gateway_manager::TRANSPORT_MODE_GATEWAY, (int)$user['id'] ); $bindings = $context['manager']->setRelayBindings($gatewayId, [[ 'relay_id' => 'relay-machine', 'device_id' => 'device-machine', 'local_ip' => '10.50.60.70', 'channel' => 0, 'metadata' => [ 'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL, ], ]], (int)$user['id']); $resolved = $context['manager']->resolveRelayBinding((int)$department['id'], 'relay-machine'); expect($mode)->toHaveKey('transport_mode', edge_gateway_manager::TRANSPORT_MODE_GATEWAY) ->and($bindings)->toHaveCount(1) ->and($resolved)->toMatchArray([ 'gateway_id' => $gatewayId, 'department_id' => (int)$department['id'], 'relay_id' => 'relay-machine', 'device_id' => 'device-machine', 'local_ip' => '10.50.60.70', 'channel' => 0, 'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL, ]) ->and((array)($resolved['metadata'] ?? [])) ->toHaveKey('fallback_mode', edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL) ->and($context['manager']->getDepartmentTransportMode((int)$department['id'])) ->toBe(edge_gateway_manager::TRANSPORT_MODE_GATEWAY); } finally { $context['cleanup']->run(); } }); /** * @return array{cleanup:ApiCleanup,fixtures:ApiFixtures,manager:edge_gateway_manager,operations:edge_gateway_operation_service,mysqli:mysqli,redis:?PredisClient} */ function edge_gateway_integration_context(): array { if (!integration_enabled()) { test()->markTestSkipped('Set RUN_INTEGRATION_TESTS=1 to run edge gateway integration tests.'); } static $bootstrapped = null; if ($bootstrapped === null) { $dbConfig = edge_gateway_integration_db_config(); $GLOBALS['CONFIG_DB'] = $dbConfig; $GLOBALS['response'] = new class { public function internal_server_error(string $message): void { throw new RuntimeException($message); } }; $db = edge_gateway_integration_wait_for_db($dbConfig); $GLOBALS['db'] = $db; $mysqli = $db->conn(); mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); $mysqli->set_charset('utf8mb4'); (new ApiSchemaBootstrap($mysqli))->ensureSchema(); $bootstrapped = [ 'mysqli' => $mysqli, 'redis' => edge_gateway_integration_redis_client(), ]; } $cleanup = new ApiCleanup(); return [ 'cleanup' => $cleanup, 'fixtures' => new ApiFixtures($bootstrapped['mysqli'], $bootstrapped['redis'], $cleanup), 'manager' => new edge_gateway_manager(), 'operations' => new edge_gateway_operation_service(), 'mysqli' => $bootstrapped['mysqli'], 'redis' => $bootstrapped['redis'], ]; } function edge_gateway_integration_wait_for_db(array $dbConfig): db { $deadline = microtime(true) + 60; $lastError = null; do { try { $db = new db($dbConfig); $db->connect(); return $db; } catch (RuntimeException $exception) { $lastError = $exception; usleep(500000); } } while (microtime(true) < $deadline); throw $lastError ?? new RuntimeException('Database connection failed before a connection attempt completed.'); } /** * @return array{host:string,user:string,password:string,database:string,port:int} */ function edge_gateway_integration_db_config(): array { $target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live'))); if ($target !== 'debug') { $target = 'live'; } $host = edge_gateway_integration_config_value('CONFIG_DB_HOST', 'CONFIG_DB_DEBUG_HOST', $target); $user = edge_gateway_integration_config_value('CONFIG_DB_USER', 'CONFIG_DB_DEBUG_USER', $target); $password = edge_gateway_integration_config_value('CONFIG_DB_PASSWORD', 'CONFIG_DB_DEBUG_PASSWORD', $target); $database = edge_gateway_integration_config_value('CONFIG_DB_DATABASE', 'CONFIG_DB_DEBUG_DATABASE', $target); $port = (int)(edge_gateway_integration_config_value('CONFIG_DB_PORT', 'CONFIG_DB_DEBUG_PORT', $target) ?: '3306'); if ($host === '' || $user === '' || $database === '') { test()->markTestSkipped('Edge gateway integration tests require configured database environment variables.'); } return [ 'host' => $host, 'user' => $user, 'password' => $password, 'database' => $database, 'port' => $port > 0 ? $port : 3306, ]; } function edge_gateway_integration_redis_client(): ?PredisClient { $target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live'))); if ($target !== 'debug') { $target = 'live'; } $host = edge_gateway_integration_config_value('REDIS_CONFIG_HOST', 'REDIS_CONFIG_DEBUG_HOST', $target); if ($host === '') { return null; } $parameters = [ 'scheme' => 'tcp', 'host' => $host, 'port' => (int)(edge_gateway_integration_config_value('REDIS_CONFIG_PORT', 'REDIS_CONFIG_DEBUG_PORT', $target) ?: '6379'), 'database' => (int)(edge_gateway_integration_config_value('REDIS_CONFIG_DATABASE', 'REDIS_CONFIG_DEBUG_DATABASE', $target) ?: '0'), 'password' => edge_gateway_integration_config_value('REDIS_CONFIG_PASSWORD', 'REDIS_CONFIG_DEBUG_PASSWORD', $target), ]; $user = edge_gateway_integration_config_value('REDIS_CONFIG_USER', 'REDIS_CONFIG_DEBUG_USER', $target); if ($user !== '') { $parameters['username'] = $user; } return new PredisClient($parameters); } function edge_gateway_integration_config_value(string $liveKey, string $debugKey, string $target): string { $liveValue = trim((string)(getenv($liveKey) ?: '')); $debugValue = trim((string)(getenv($debugKey) ?: '')); if ($target === 'debug' && $debugValue !== '') { return $debugValue; } return $liveValue; } function edge_gateway_integration_set_heartbeat_age(mysqli $mysqli, ?PredisClient $redis, int $gatewayId, int $secondsAgo): void { $result = $mysqli->query("SELECT last_heartbeat_at, updated_at, created_at FROM edge_gateways WHERE id = " . (int)$gatewayId . " LIMIT 1"); $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; if ($result instanceof mysqli_result) { $result->free(); } $referenceTimestamp = strtotime((string)($row['last_heartbeat_at'] ?? $row['updated_at'] ?? $row['created_at'] ?? '')); if ($referenceTimestamp === false || $referenceTimestamp <= 0) { $referenceTimestamp = time(); } $timestamp = date('Y-m-d H:i:s', $referenceTimestamp - max(0, $secondsAgo)); $escaped = $mysqli->real_escape_string($timestamp); $mysqli->query("UPDATE edge_gateways SET last_heartbeat_at = '{$escaped}', status = 'ONLINE' WHERE id = " . (int)$gatewayId); if ($redis !== null) { foreach ([ 'obj_prop:*:' . $gatewayId . ':status', 'obj_prop:*:' . $gatewayId . ':last_heartbeat_at', 'obj_prop:*:' . $gatewayId . ':updated_at', 'edge_gateway:view:v1:*', ] as $pattern) { $keys = $redis->keys($pattern); if (is_array($keys) && $keys !== []) { $redis->del($keys); } } } } function edge_gateway_integration_inventory(string $suffix): array { return [[ 'device_id' => 'integration-' . $suffix, 'local_ip' => '10.50.60.70', 'model' => 'TruckWash Integration Gateway', 'channel_count' => 1, 'online' => true, 'capabilities' => [ 'gateway_management_v2' => true, ], 'metadata' => [ 'hostname' => 'integration-' . $suffix, ], ]]; } /** * @param mixed $rows * @return array */ function edge_gateway_integration_messages(mixed $rows): array { if (!is_array($rows)) { return []; } $messages = []; foreach ($rows as $row) { if (is_array($row) && isset($row['message']) && is_string($row['message'])) { $messages[] = $row['message']; } } return $messages; }