Files
api/services/nginx/app/tests/Unit/Selfserve/EdgeGatewayManagerHeartbeatStatusTest.php
T

153 lines
6.3 KiB
PHP

<?php
app_require('classes/edge_gateway_manager.php');
use classes\edge_gateway_manager;
it('derives effective gateway status from heartbeat freshness', function (
string $reportedStatus,
?string $lastHeartbeatAt,
string $currentTime,
string $expectedStatus
): void {
$effectiveStatus = edge_gateway_manager::resolveGatewayStatus(
$reportedStatus,
$lastHeartbeatAt,
strtotime($currentTime)
);
expect($effectiveStatus)->toBe($expectedStatus);
})->with([
'recent heartbeat stays online' => [
edge_gateway_manager::STATUS_ONLINE,
'2026-04-08 10:00:00',
'2026-04-08 10:00:59',
edge_gateway_manager::STATUS_ONLINE,
],
'late heartbeat degrades after one minute' => [
edge_gateway_manager::STATUS_ONLINE,
'2026-04-08 10:00:00',
'2026-04-08 10:01:00',
edge_gateway_manager::STATUS_DEGRADED,
],
'stale heartbeat goes offline after five minutes' => [
edge_gateway_manager::STATUS_ONLINE,
'2026-04-08 10:00:00',
'2026-04-08 10:05:00',
edge_gateway_manager::STATUS_OFFLINE,
],
'explicit offline reports stay offline even when heartbeat is fresh' => [
edge_gateway_manager::STATUS_OFFLINE,
'2026-04-08 10:04:55',
'2026-04-08 10:05:00',
edge_gateway_manager::STATUS_OFFLINE,
],
'missing heartbeat is treated as offline' => [
edge_gateway_manager::STATUS_ONLINE,
null,
'2026-04-08 10:05:00',
edge_gateway_manager::STATUS_OFFLINE,
],
]);
it('marks ready discovery as stale when the gateway heartbeat has expired', function (): void {
$gateway = edge_gateway_manager::deriveGatewayRuntimeState([
'status' => edge_gateway_manager::STATUS_ONLINE,
'last_heartbeat_at' => '2026-04-08 10:00:00',
'discovery_status' => 'READY',
], strtotime('2026-04-08 21:00:00'));
expect($gateway['status'])->toBe(edge_gateway_manager::STATUS_OFFLINE);
expect($gateway['discovery_status'])->toBe('STALE');
});
it('merges incoming heartbeat metadata with existing gateway metadata', function (): void {
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
expect($source)->toContain("\$existingMetadata = (array)(\$gateway->metadata_json->value() ?? []);");
expect($source)->toContain("\$gateway->metadata_json->set(array_merge(\$existingMetadata, (array)(\$payload['metadata'] ?? [])));");
});
it('derives relay fallback and transport health details without shell or update runtime state', function (): void {
$gateway = edge_gateway_manager::deriveGatewayRuntimeState([
'status' => edge_gateway_manager::STATUS_ONLINE,
'last_heartbeat_at' => '2026-04-08 10:04:30',
'department_transport_mode' => edge_gateway_manager::TRANSPORT_MODE_GATEWAY,
'metadata' => [
'last_sync_at' => '2026-04-08 10:04:20',
'broker_presence' => [
'connected' => false,
'last_seen_at' => '2026-04-08 10:03:00',
'last_error' => 'broker timeout',
],
'control_plane_status' => [
'last_successful_sync_at' => '2026-04-08 10:04:10',
'last_transport_failure_at' => '2026-04-08 10:04:12',
'last_transport_error' => 'POST https://api.truckwash.io/edge-agent/gateways/17/heartbeat returned HTTP 502',
],
],
'operational_snapshot' => [
'command_backlog' => 2,
'operation_backlog' => 1,
],
'active_operation' => [
'id' => 91,
'type' => 'DISCOVERY',
'status' => 'IN_PROGRESS',
'started_at' => '2026-04-08 09:30:00',
],
'bindings' => [
[
'id' => 1,
'relay_id' => 'M-7',
'device_id' => 'shelly-plus-01',
'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_PREFER_LOCAL,
],
[
'id' => 2,
'relay_id' => 'M-7-CANARY',
'device_id' => 'missing-device',
'fallback_mode' => edge_gateway_manager::RELAY_FALLBACK_LOCAL_ONLY,
],
],
'inventory' => [
[
'device_id' => 'shelly-plus-01',
'online' => true,
'last_seen_at' => '2026-04-08 09:55:00',
],
],
'recent_commands' => [
[
'status' => 'COMPLETED',
'command_type' => 'DISCOVER_SHELLY',
'completed_at' => '2026-04-08 10:02:00',
],
],
], strtotime('2026-04-08 10:05:00'));
expect($gateway['channel_status']['command']['active'])->toBe(edge_gateway_manager::DELIVERY_CHANNEL_API);
expect($gateway['channel_status']['broker']['state'])->toBe(edge_gateway_manager::STATUS_OFFLINE);
expect($gateway['channel_status'])->not->toHaveKey('shell');
expect($gateway['relay_health'][0]['execution_path'])->toBe('cloud');
expect($gateway['relay_health'][0]['reason'])->toBe('device_stale');
expect($gateway['relay_health'][0]['recommended_action'])->toBe('retry_discovery');
expect($gateway['relay_health'][1]['execution_path'])->toBe('local');
expect($gateway['relay_health'][1]['reason'])->toBe('device_missing');
expect($gateway['fallback_summary']['cloud_relays'])->toBe(1);
expect($gateway['fallback_summary']['local_only_relays'])->toBe(1);
expect($gateway['transport_health']['status'])->toBe(edge_gateway_manager::STATUS_DEGRADED);
expect($gateway['transport_health']['recommended_action'])->toBe('retry_discovery');
expect($gateway['transport_health']['last_successful_sync_at'])->toBe('2026-04-08 10:04:30');
expect($gateway['transport_health']['last_transport_failure_at'])->toBe('2026-04-08 10:04:12');
expect($gateway['transport_health']['last_transport_error'])->toContain('returned HTTP 502');
expect($gateway['last_sync_at'])->toBe('2026-04-08 10:04:30');
expect($gateway['last_successful_discovery_at'])->toBe('2026-04-08 10:02:00');
expect($gateway['diagnostics'])->not->toBeEmpty();
expect($gateway['error_state']['code'])->toBe('EDGE_GATEWAY_OPERATION_TIMEOUT');
expect($gateway['backlog_depth'])->toBe([
'commands' => 2,
'operations' => 1,
]);
});