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, ]); });