From 56685d7bf3830e17fcc9bfe1951fec1084596281 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 15 Apr 2026 11:53:45 +0200 Subject: [PATCH] Add tests and functionality for edge gateway updates and lifecycle This commit introduces unit tests, E2E tests, and implementation updates related to edge gateway lifecycle management, including update handling, artifact validation, and rollback mechanisms. It also refines routing, component interaction, and backend methods to improve update tracking, status transitions, and artifact management. --- services/edge-agent/test/agent.test.mjs | 181 +++++++++++++++++- .../app/classes/edge_gateway_manager.php | 161 +++++++++++++++- .../EdgeGatewayManagerCommandQueueTest.php | 8 +- .../Selfserve/EdgeGatewayManagerUrlTest.php | 4 + .../EdgeGatewayUpdateLifecycleTest.php | 22 +++ .../Selfserve/GatewayShellyTransportTest.php | 45 +++++ .../Selfserve/ShellyTransportResolverTest.php | 77 ++++++++ 7 files changed, 487 insertions(+), 11 deletions(-) create mode 100644 services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php create mode 100644 services/nginx/app/tests/Unit/Selfserve/ShellyTransportResolverTest.php diff --git a/services/edge-agent/test/agent.test.mjs b/services/edge-agent/test/agent.test.mjs index 517d7e94..da949a33 100644 --- a/services/edge-agent/test/agent.test.mjs +++ b/services/edge-agent/test/agent.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { execFile as execFileCallback } from "node:child_process"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -11,17 +11,30 @@ import { buildStatusReport, claimIfNeeded, createShellBridge, + finalizePendingUpdateOnStartup, getAgentStatus, getRelayStatus, parseCliArgs, runCli, + runUpdate, setRelayState, startAgent, + verifyPendingUpdate, } from "../dist/agent.mjs"; const execFile = promisify(execFileCallback); const agentEntryPath = fileURLToPath(new URL("../dist/agent.mjs", import.meta.url)); +function makeFetchResponse(body) { + const bytes = Buffer.from(body); + return { + ok: true, + async arrayBuffer() { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + }, + }; +} + async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, description = "condition" } = {}) { const deadline = Date.now() + timeoutMs; @@ -103,6 +116,172 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi assert.equal(switched.on, false); }); +test("runUpdate stages a pending verification restart after installing new artifacts", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-update-")); + const configPath = path.join(tempDir, "config.json"); + const liveConfig = { + apiUrl: "https://api.example.test", + gatewayId: 42, + agentToken: "agent-token", + installDir: tempDir, + restartMode: "spawn", + installedVersion: "1.0.0", + targetVersion: "1.0.0", + }; + + await writeFile(configPath, JSON.stringify(liveConfig, null, 2)); + await writeFile(path.join(tempDir, "agent.mjs"), "// old agent\n"); + await writeFile(path.join(tempDir, "package.json"), JSON.stringify({ name: "old-edge-agent" }, null, 2)); + + const execCalls = []; + const fakeExecFile = async (command, args, options) => { + execCalls.push({ command, args, options }); + return { stdout: "{}" }; + }; + const fakeFetch = async (url) => { + if (String(url).endsWith("/agent.mjs")) { + return makeFetchResponse("// new agent\n"); + } + if (String(url).endsWith("/package.json")) { + return makeFetchResponse(JSON.stringify({ name: "new-edge-agent" }, null, 2)); + } + throw new Error(`Unexpected URL: ${url}`); + }; + + const result = await runUpdate({ + artifactUrl: "https://api.example.test/edge-agent/artifacts/agent.mjs", + packageUrl: "https://api.example.test/edge-agent/artifacts/package.json", + targetVersion: "1.1.0", + releaseChannel: "stable", + restartMode: "spawn", + }, fakeFetch, { + configPath, + config: liveConfig, + liveConfig, + execFileImpl: fakeExecFile, + }); + + const persistedConfig = JSON.parse(await readFile(configPath, "utf8")); + + assert.equal(result.__agentCommandEnvelope, true); + assert.equal(result.payload.verification_pending, true); + assert.equal(result.payload.target_version, "1.1.0"); + assert.equal(liveConfig.targetVersion, "1.1.0"); + assert.equal(liveConfig.installedVersion, "1.0.0"); + assert.equal(persistedConfig.pendingUpdate.targetVersion, "1.1.0"); + assert.equal(persistedConfig.pendingUpdate.previousVersion, "1.0.0"); + assert.match(persistedConfig.pendingUpdate.backupDir, /\.updates/); + assert.equal(await readFile(path.join(tempDir, "agent.mjs"), "utf8"), "// new agent\n"); + assert.equal(execCalls.length, 2); + assert.deepEqual(execCalls[0].args, ["install", "--omit=dev"]); + assert.deepEqual(execCalls[1].args, [path.join(tempDir, "agent.mjs"), "status", "--config", configPath]); + + await rm(tempDir, { recursive: true, force: true }); +}); + +test("finalizePendingUpdateOnStartup promotes the target version and clears the pending update marker", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-startup-")); + const configPath = path.join(tempDir, "config.json"); + const config = { + apiUrl: "https://api.example.test", + gatewayId: 42, + agentToken: "agent-token", + installDir: tempDir, + installedVersion: "1.0.0", + targetVersion: "1.1.0", + pendingUpdate: { + targetVersion: "1.1.0", + previousVersion: "1.0.0", + releaseChannel: "stable", + backupDir: path.join(tempDir, ".updates", "backup"), + }, + }; + await writeFile(configPath, JSON.stringify(config, null, 2)); + + const finalized = await finalizePendingUpdateOnStartup(config, configPath, { + liveConfig: config, + }); + const persisted = JSON.parse(await readFile(configPath, "utf8")); + + assert.equal(finalized.installedVersion, "1.1.0"); + assert.equal(finalized.pendingUpdate, null); + assert.equal(finalized.lastUpdate.state, "COMPLETED"); + assert.equal(persisted.installedVersion, "1.1.0"); + assert.equal(persisted.pendingUpdate, null); + assert.equal(persisted.lastUpdate.targetVersion, "1.1.0"); + + await rm(tempDir, { recursive: true, force: true }); +}); + +test("verifyPendingUpdate rolls back the previous files and respawns the agent when startup verification times out", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-rollback-")); + const updatesDir = path.join(tempDir, ".updates", "rollback-case"); + const configPath = path.join(tempDir, "config.json"); + + await mkdir(updatesDir, { recursive: true }); + await writeFile(path.join(tempDir, "agent.mjs"), "// broken new agent\n"); + await writeFile(path.join(tempDir, "package.json"), JSON.stringify({ name: "broken-edge-agent" }, null, 2)); + await writeFile(path.join(updatesDir, "agent.mjs"), "// old agent\n"); + await writeFile(path.join(updatesDir, "package.json"), JSON.stringify({ name: "old-edge-agent" }, null, 2)); + + const config = { + apiUrl: "https://api.example.test", + gatewayId: 42, + agentToken: "agent-token", + installDir: tempDir, + restartMode: "spawn", + installedVersion: "1.0.0", + targetVersion: "1.1.0", + pendingUpdate: { + targetVersion: "1.1.0", + previousVersion: "1.0.0", + releaseChannel: "stable", + backupDir: updatesDir, + installDir: tempDir, + restartMode: "spawn", + verificationTimeoutSeconds: 5, + }, + }; + await writeFile(configPath, JSON.stringify(config, null, 2)); + + const execCalls = []; + const fakeExecFile = async (command, args, options) => { + execCalls.push({ command, args, options }); + return { stdout: "{}" }; + }; + const spawnCalls = []; + const fakeSpawn = (command, args, options) => { + spawnCalls.push({ command, args, options }); + return { + unref() {}, + }; + }; + + const result = await verifyPendingUpdate(configPath, { + execFileImpl: fakeExecFile, + spawnImpl: fakeSpawn, + timeoutMs: 1, + waitImpl: async () => {}, + verifyIntervalMs: 1, + }); + + const persisted = JSON.parse(await readFile(configPath, "utf8")); + + assert.equal(result.verified, false); + assert.equal(result.rolledBack, true); + assert.equal(await readFile(path.join(tempDir, "agent.mjs"), "utf8"), "// old agent\n"); + assert.equal(persisted.pendingUpdate, null); + assert.equal(persisted.lastUpdate.state, "ROLLED_BACK"); + assert.equal(persisted.installedVersion, "1.0.0"); + assert.equal(execCalls.length, 1); + assert.deepEqual(execCalls[0].args, ["install", "--omit=dev"]); + assert.equal(spawnCalls.length, 1); + assert.equal(spawnCalls[0].command, process.execPath); + assert.deepEqual(spawnCalls[0].args, [path.join(tempDir, "agent.mjs"), "--config", configPath]); + + await rm(tempDir, { recursive: true, force: true }); +}); + test("shell bridge proxies PTY output, input, resize, close, and dispose events", async () => { const messages = []; const createdPtys = []; diff --git a/services/nginx/app/classes/edge_gateway_manager.php b/services/nginx/app/classes/edge_gateway_manager.php index 1e4cf62b..46f6968a 100644 --- a/services/nginx/app/classes/edge_gateway_manager.php +++ b/services/nginx/app/classes/edge_gateway_manager.php @@ -26,6 +26,7 @@ class edge_gateway_manager public const STATUS_DEGRADED = 'DEGRADED'; public const STATUS_OFFLINE = 'OFFLINE'; public const DEFAULT_RELEASE_CHANNEL = 'stable'; + public const DEFAULT_AGENT_SERVICE_NAME = 'truckwash-edge-agent.service'; public const INSTALL_TOKEN_TTL_SECONDS = 1800; public const SHELL_SESSION_TTL_SECONDS = 900; public const HEARTBEAT_DEGRADED_AFTER_SECONDS = 60; @@ -184,6 +185,8 @@ class edge_gateway_manager $this->syncDeviceInventory($gatewayId, $payload['inventory']); } + $this->applyHeartbeatUpdateLifecycle($gateway, $payload); + return $this->getGateway($gatewayId); } @@ -361,10 +364,12 @@ class edge_gateway_manager ]); $jobObject->select($jobId); - $command = $this->createCommandJob($gatewayId, 'RUN_UPDATE', [ - 'targetVersion' => $targetVersion, - 'releaseChannel' => $releaseChannel, - ], $userId); + $command = $this->createCommandJob( + $gatewayId, + 'RUN_UPDATE', + $this->buildUpdateCommandPayload($targetVersion, $releaseChannel), + $userId + ); $jobObject->command_job_id->set((int)$command->id); $this->writeAudit( @@ -874,9 +879,12 @@ class edge_gateway_manager 'installToken' => $plainToken, 'gatewayId' => null, 'agentToken' => null, + 'installDir' => '/opt/truckwash-edge-agent', + 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, 'heartbeatIntervalSeconds' => 15, 'commandPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS, 'shellActionPollTimeoutSeconds' => self::SHELL_ACTION_POLL_TIMEOUT_SECONDS, + 'updateVerificationTimeoutSeconds' => 45, ], JSON_UNESCAPED_SLASHES); $script = <<<'BASH' @@ -918,12 +926,53 @@ BASH; return strtr($script, [ '__VERIFY_URL__' => $this->buildInstallTokenVerifyUrl($plainToken), - '__PACKAGE_URL__' => rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/artifacts/package.json', - '__AGENT_URL__' => rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/artifacts/agent.mjs', + '__PACKAGE_URL__' => $this->buildAgentArtifactUrl('package.json'), + '__AGENT_URL__' => $this->buildAgentArtifactUrl('agent.mjs'), '__CONFIG_JSON__' => (string)$configJson, ]); } + private function buildUpdateCommandPayload(string $targetVersion, string $releaseChannel): array + { + return [ + 'targetVersion' => $targetVersion, + 'releaseChannel' => $releaseChannel, + 'artifactUrl' => $this->buildAgentArtifactUrl('agent.mjs'), + 'artifactSha256' => $this->buildAgentArtifactSha256('agent.mjs'), + 'packageUrl' => $this->buildAgentArtifactUrl('package.json'), + 'packageSha256' => $this->buildAgentArtifactSha256('package.json'), + 'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME, + ]; + } + + private function buildAgentArtifactUrl(string $fileName): string + { + return rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/artifacts/' . $fileName; + } + + private function buildAgentArtifactPath(string $fileName): string + { + return dirname(WD, 3) . '/services/edge-agent/dist/' . $fileName; + } + + /** + * @throws Exception + */ + private function buildAgentArtifactSha256(string $fileName): string + { + $artifactPath = $this->buildAgentArtifactPath($fileName); + if (!is_file($artifactPath)) { + throw new Exception('Missing edge agent artifact: ' . $fileName); + } + + $sha256 = hash_file('sha256', $artifactPath); + if ($sha256 === false) { + throw new Exception('Unable to checksum edge agent artifact: ' . $fileName); + } + + return $sha256; + } + /** * @throws Exception */ @@ -1570,7 +1619,11 @@ BASH; } if ($commandType === 'RUN_UPDATE') { - $this->finalizeLinkedUpdateJob((int)$job->id, $ok, $payload, $errorMessage); + if ($ok && !empty($payload['verification_pending'])) { + $this->markLinkedUpdateJobVerifying((int)$job->id, $payload); + } else { + $this->finalizeLinkedUpdateJob((int)$job->id, $ok, $payload, $errorMessage); + } } } @@ -1584,9 +1637,13 @@ BASH; if ($updateJob->started_at->value() === null) { $updateJob->started_at->set($this->now()); } + + if ((string)$updateJob->status->value() === 'PENDING') { + $updateJob->status->set('DISPATCHING'); + } } - private function finalizeLinkedUpdateJob(int $commandJobId, bool $ok, array $payload, ?string $errorMessage): void + private function markLinkedUpdateJobVerifying(int $commandJobId, array $payload): void { $updateJob = $this->findLinkedUpdateJobByCommandId($commandJobId); if ($updateJob === null) { @@ -1597,7 +1654,28 @@ BASH; $updateJob->started_at->set($this->now()); } - $updateJob->status->set($ok ? 'COMPLETED' : 'FAILED'); + $updateJob->status->set('VERIFYING'); + $updateJob->result_json->set($payload); + } + + private function finalizeLinkedUpdateJob( + int $commandJobId, + bool $ok, + array $payload, + ?string $errorMessage, + ?string $finalStatus = null + ): void + { + $updateJob = $this->findLinkedUpdateJobByCommandId($commandJobId); + if ($updateJob === null) { + return; + } + + if ($updateJob->started_at->value() === null) { + $updateJob->started_at->set($this->now()); + } + + $updateJob->status->set($finalStatus ?? ($ok ? 'COMPLETED' : 'FAILED')); $updateJob->completed_at->set($this->now()); $updateJob->result_json->set($ok ? $payload @@ -1618,6 +1696,71 @@ BASH; return (new edge_gateway_update_jobs_o())->select((int)$rows[0]['id']); } + private function findLatestActiveUpdateJobForGateway(int $gatewayId, ?string $targetVersion = null): ?edge_gateway_update_jobs_o + { + $statement = db::getPDO()->prepare( + 'SELECT id + FROM edge_gateway_update_jobs + WHERE gateway_id = :gateway_id + AND deleted_at IS NULL + AND status NOT IN (\'COMPLETED\', \'FAILED\', \'ROLLED_BACK\')' + . ($targetVersion !== null && trim($targetVersion) !== '' ? ' AND target_version = :target_version' : '') + . ' ORDER BY requested_at DESC, id DESC + LIMIT 1' + ); + + $parameters = [ + ':gateway_id' => $gatewayId, + ]; + if ($targetVersion !== null && trim($targetVersion) !== '') { + $parameters[':target_version'] = trim($targetVersion); + } + + $statement->execute($parameters); + $row = $statement->fetch(); + if (!is_array($row) || !isset($row['id'])) { + return null; + } + + return (new edge_gateway_update_jobs_o())->select((int)$row['id']); + } + + private function applyHeartbeatUpdateLifecycle(edge_gateways_o $gateway, array $payload): void + { + $metadata = isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []; + $lastUpdate = isset($metadata['last_update']) && is_array($metadata['last_update']) + ? (array)$metadata['last_update'] + : []; + + if ($lastUpdate === []) { + return; + } + + $state = strtoupper(trim((string)($lastUpdate['state'] ?? ''))); + if (!in_array($state, ['COMPLETED', 'FAILED', 'ROLLED_BACK'], true)) { + return; + } + + $targetVersion = trim((string)($lastUpdate['target_version'] ?? '')); + $updateJob = $this->findLatestActiveUpdateJobForGateway((int)$gateway->id, $targetVersion !== '' ? $targetVersion : null); + if ($updateJob === null) { + return; + } + + if ($updateJob->started_at->value() === null) { + $updateJob->started_at->set($this->now()); + } + + $result = array_merge($lastUpdate, [ + 'heartbeat_installed_version' => $payload['installed_version'] ?? $gateway->installed_version->value(), + 'heartbeat_target_version' => $payload['target_version'] ?? $gateway->target_version->value(), + ]); + + $updateJob->status->set($state); + $updateJob->completed_at->set($this->now()); + $updateJob->result_json->set($result); + } + /** * @param array> $inventory */ diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php index 3e022fce..8be17018 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerCommandQueueTest.php @@ -18,7 +18,11 @@ it('queues admin commands, exposes agent poll/result handlers, and keeps heartbe expect($source)->toContain("\$gateway->discovery_status->set('PENDING');"); expect($source)->toContain("\$gateway->discovery_status->set('READY');"); expect($source)->toContain("\$gateway->discovery_status->set('FAILED');"); - expect($source)->toContain("\$updateJob->status->set(\$ok ? 'COMPLETED' : 'FAILED');"); + expect($source)->toContain("\$this->buildUpdateCommandPayload(\$targetVersion, \$releaseChannel)"); + expect($source)->toContain("\$updateJob->status->set('DISPATCHING');"); + expect($source)->toContain("\$updateJob->status->set('VERIFYING');"); + expect($source)->toContain("\$updateJob->status->set(\$finalStatus ?? (\$ok ? 'COMPLETED' : 'FAILED'));"); + expect($source)->toContain("\$this->applyHeartbeatUpdateLifecycle(\$gateway, \$payload);"); expect($source)->not->toContain("\$gateway->discovery_status->set((string)(\$payload['discovery_status'] ?? \$gateway->discovery_status->value()));"); }); @@ -29,6 +33,8 @@ it('defines the dispatchable gateway guard and api-polled shell queue on the loa expect($reflection->getMethod('requireDispatchableGateway')->isPrivate())->toBeTrue(); expect($reflection->hasMethod('queueDiscovery'))->toBeTrue(); expect($reflection->hasMethod('queueUpdate'))->toBeTrue(); + expect($reflection->hasMethod('buildUpdateCommandPayload'))->toBeTrue(); + expect($reflection->hasMethod('applyHeartbeatUpdateLifecycle'))->toBeTrue(); expect($reflection->hasMethod('dispatchRelayStatus'))->toBeTrue(); expect($reflection->hasMethod('dispatchRelaySwitch'))->toBeTrue(); expect($reflection->hasMethod('pollShellAction'))->toBeTrue(); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php index 5a75d191..7ba75d03 100644 --- a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerUrlTest.php @@ -47,8 +47,11 @@ it('builds install script urls with forwarded https scheme when proxied', functi expect($script)->toContain('curl -fsSL "https://api.truckwash.io:4433/edge-agent/artifacts/package.json" -o "$INSTALL_DIR/package.json"'); expect($script)->toContain('curl -fsSL "https://api.truckwash.io:4433/edge-agent/artifacts/agent.mjs" -o "$INSTALL_DIR/agent.mjs"'); expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"'); + expect($script)->toContain('"installDir":"/opt/truckwash-edge-agent"'); + expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"'); expect($script)->toContain('"commandPollTimeoutSeconds":20'); expect($script)->toContain('"shellActionPollTimeoutSeconds":20'); + expect($script)->toContain('"updateVerificationTimeoutSeconds":45'); expect($script)->not->toContain('"brokerUrl"'); expect($script)->not->toContain('Undefined variable $INSTALL_DIR'); }); @@ -76,6 +79,7 @@ it('infers https for the staging api host when only the https port is present', expect($manager->getApiBaseUrl())->toBe('https://api.truckwash.io:4433'); expect($script)->toContain('"apiUrl":"https://api.truckwash.io:4433"'); + expect($script)->toContain('"serviceName":"truckwash-edge-agent.service"'); expect($script)->not->toContain('"brokerUrl"'); }); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php new file mode 100644 index 00000000..c665ce98 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayUpdateLifecycleTest.php @@ -0,0 +1,22 @@ +not->toBeFalse(); + expect($source)->toContain("'artifactUrl' => \$this->buildAgentArtifactUrl('agent.mjs')"); + expect($source)->toContain("'artifactSha256' => \$this->buildAgentArtifactSha256('agent.mjs')"); + expect($source)->toContain("'packageUrl' => \$this->buildAgentArtifactUrl('package.json')"); + expect($source)->toContain("'packageSha256' => \$this->buildAgentArtifactSha256('package.json')"); + expect($source)->toContain("'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME"); +}); + +it('finalizes active update jobs from heartbeat metadata when the agent reports completion or rollback', function (): void { + $source = file_get_contents(app_path('classes/edge_gateway_manager.php')); + + expect($source)->toContain("\$state = strtoupper(trim((string)(\$lastUpdate['state'] ?? '')));"); + expect($source)->toContain("if (!in_array(\$state, ['COMPLETED', 'FAILED', 'ROLLED_BACK'], true)) {"); + expect($source)->toContain("\$updateJob->status->set(\$state);"); + expect($source)->toContain("'heartbeat_installed_version' => \$payload['installed_version'] ?? \$gateway->installed_version->value()"); + expect($source)->toContain("'heartbeat_target_version' => \$payload['target_version'] ?? \$gateway->target_version->value()"); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/GatewayShellyTransportTest.php b/services/nginx/app/tests/Unit/Selfserve/GatewayShellyTransportTest.php index 1ee3372f..6a4b857c 100644 --- a/services/nginx/app/tests/Unit/Selfserve/GatewayShellyTransportTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/GatewayShellyTransportTest.php @@ -12,6 +12,8 @@ class GatewayShellyTransportManagerFake extends edge_gateway_manager public array $statusCalls = []; /** @var array> */ public array $switchCalls = []; + public ?Exception $statusException = null; + public ?Exception $switchException = null; public function __construct() { @@ -19,6 +21,10 @@ class GatewayShellyTransportManagerFake extends edge_gateway_manager public function dispatchRelayStatus(int $departmentId, string $logicalRelayId): array { + if ($this->statusException instanceof Exception) { + throw $this->statusException; + } + $this->statusCalls[] = [ 'department_id' => $departmentId, 'relay_id' => $logicalRelayId, @@ -33,6 +39,10 @@ class GatewayShellyTransportManagerFake extends edge_gateway_manager public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array { + if ($this->switchException instanceof Exception) { + throw $this->switchException; + } + $this->switchCalls[] = [ 'department_id' => $departmentId, 'relay_id' => $logicalRelayId, @@ -81,3 +91,38 @@ it('maps gateway relay switch responses into the Shelly cloud payload shape', fu expect($result[0]['id'])->toBe('relay-machine'); expect($result[0]['status']['switch:0']['output'])->toBeFalse(); }); + +it('requires a valid department id for gateway transport requests', function (): void { + $transport = new gateway_shelly_transport(new GatewayShellyTransportManagerFake()); + + expect(fn() => $transport->sendPostRequest('/v2/devices/api/get', ['ids' => ['relay-machine']], null)) + ->toThrow(Exception::class, 'A department_id is required for gateway Shelly transport'); +}); + +it('rejects unsupported gateway transport endpoints', function (): void { + $transport = new gateway_shelly_transport(new GatewayShellyTransportManagerFake()); + + expect(fn() => $transport->sendPostRequest('/v2/devices/api/unknown', [], 17)) + ->toThrow(Exception::class, 'Unsupported gateway Shelly transport endpoint'); +}); + +it('surfaces binding lookup failures while resolving relay state through the gateway transport', function (): void { + $manager = new GatewayShellyTransportManagerFake(); + $manager->statusException = new Exception('Relay binding missing'); + $transport = new gateway_shelly_transport($manager); + + expect(fn() => $transport->sendPostRequest('/v2/devices/api/get', [ + 'ids' => ['relay-machine'], + ], 17))->toThrow(Exception::class, 'Relay binding missing'); +}); + +it('surfaces offline gateway failures while dispatching relay switch commands', function (): void { + $manager = new GatewayShellyTransportManagerFake(); + $manager->switchException = new Exception('Gateway agent is offline'); + $transport = new gateway_shelly_transport($manager); + + expect(fn() => $transport->sendPostRequest('/v2/devices/api/set/switch', [ + 'id' => 'relay-machine', + 'on' => true, + ], 17))->toThrow(Exception::class, 'Gateway agent is offline'); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/ShellyTransportResolverTest.php b/services/nginx/app/tests/Unit/Selfserve/ShellyTransportResolverTest.php new file mode 100644 index 00000000..b32c7173 --- /dev/null +++ b/services/nginx/app/tests/Unit/Selfserve/ShellyTransportResolverTest.php @@ -0,0 +1,77 @@ + */ + public array $modesByDepartment = []; + /** @var array */ + public array $calls = []; + + public function __construct() + { + } + + public function getDepartmentTransportMode(int $departmentId): string + { + $this->calls[] = $departmentId; + + return $this->modesByDepartment[$departmentId] ?? self::TRANSPORT_MODE_CLOUD; + } +} + +class ShellyTransportResolverTransportFake implements shelly_transport_i +{ + public function __construct(public readonly string $name) + { + } + + public function requireModuleEnabled(): void + { + } + + public function requireValidSecretKey(): void + { + } + + public function sendPostRequest(string $endpoint, array $data, ?int $department_id = null): array|object|null + { + return [ + 'name' => $this->name, + 'endpoint' => $endpoint, + 'department_id' => $department_id, + ]; + } +} + +it('resolves the injected gateway transport when a department is in gateway mode', function (): void { + $manager = new ShellyTransportResolverManagerFake(); + $manager->modesByDepartment[17] = edge_gateway_manager::TRANSPORT_MODE_GATEWAY; + + $cloudTransport = new ShellyTransportResolverTransportFake('cloud'); + $gatewayTransport = new ShellyTransportResolverTransportFake('gateway'); + + $resolver = new shelly_transport_resolver($manager, $cloudTransport, $gatewayTransport); + + expect($resolver->resolveForDepartment(17))->toBe($gatewayTransport); + expect($manager->calls)->toBe([17]); +}); + +it('resolves the injected cloud transport when a department is in cloud mode', function (): void { + $manager = new ShellyTransportResolverManagerFake(); + $manager->modesByDepartment[22] = edge_gateway_manager::TRANSPORT_MODE_CLOUD; + + $cloudTransport = new ShellyTransportResolverTransportFake('cloud'); + $gatewayTransport = new ShellyTransportResolverTransportFake('gateway'); + + $resolver = new shelly_transport_resolver($manager, $cloudTransport, $gatewayTransport); + + expect($resolver->resolveForDepartment(22))->toBe($cloudTransport); + expect($manager->calls)->toBe([22]); +});