Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions app/Actions/PingHostname.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,36 @@
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Ping\Ping;
use Spatie\Ping\PingResult;
use Throwable;

class PingHostname
{
use AsAction;

public function handle(?string $hostname = null, int $count = 1): PingResult
/**
* Attempt to ping the given hostname. Returns null when the ping binary
* is unavailable or another OS-level error prevents execution.
*/
public function handle(?string $hostname = null, int $count = 1): ?PingResult
{
$hostname = $hostname ?? config('speedtest.preflight.internet_check_hostname');

// Remove protocol if present
$hostname = preg_replace('#^https?://#', '', $hostname);

$ping = (new Ping(
hostname: $hostname,
count: $count,
))->run();
try {
$ping = (new Ping(
hostname: $hostname,
count: $count,
))->run();
} catch (Throwable $e) {
Log::debug('Ping command unavailable', [
'host' => $hostname,
'error' => $e->getMessage(),
]);

return null;
}

$data = $ping->toArray();
unset($data['raw_output'], $data['lines']);
Expand Down
51 changes: 49 additions & 2 deletions app/Jobs/CheckForInternetConnectionJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\SkipIfBatchCancelled;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;

class CheckForInternetConnectionJob implements ShouldQueue
{
Expand Down Expand Up @@ -46,11 +49,20 @@ public function handle(): void

$ping = PingHostname::run();

if ($ping->isSuccess()) {
if ($ping?->isSuccess()) {
return;
}

$message = sprintf('Failed to connected to hostname "%s". Error received "%s".', $ping->getHost(), $ping->error()?->value);
Log::debug('Pinged failed, falling back to HTTP connectivity check');

// Ping either failed or was unavailable — attempt an HTTP fallback.
if ($this->httpFallbackSucceeds()) {
return;
}

$message = $ping === null
? 'Ping command is unavailable and HTTP fallback also failed.'
: sprintf('Failed to connected to hostname "%s". Error received "%s". HTTP fallback also failed.', $ping->getHost(), $ping->error()?->value);

$this->result->update([
'data->type' => 'log',
Expand All @@ -63,4 +75,39 @@ public function handle(): void

$this->batch()->cancel();
}

/**
* Attempt to verify connectivity via an HTTP GET request as a fallback
* when ping is unavailable or unsuccessful.
*/
protected function httpFallbackSucceeds(): bool
{
$url = config('speedtest.preflight.external_ip_url');

try {
$response = Http::retry(3, 100)
->timeout(5)
->get(url: $url);

if ($response->ok()) {
Log::debug('HTTP fallback connectivity check succeeded', ['url' => $url]);

return true;
}

Log::debug('HTTP fallback connectivity check received non-OK response', [
'url' => $url,
'status' => $response->status(),
]);

return false;
} catch (Throwable $e) {
Log::debug('HTTP fallback connectivity check failed', [
'url' => $url,
'error' => $e->getMessage(),
]);

return false;
}
}
}
4 changes: 2 additions & 2 deletions config/speedtest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
/**
* General settings.
*/
'build_date' => Carbon::parse('2026-02-03'),
'build_date' => Carbon::parse('2026-02-04'),

'build_version' => 'v1.13.6',
'build_version' => 'v1.13.7',

'content_width' => env('CONTENT_WIDTH', '7xl'),

Expand Down
152 changes: 152 additions & 0 deletions tests/Feature/CheckForInternetConnectionJobTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?php

use App\Actions\PingHostname;
use App\Enums\ResultStatus;
use App\Events\SpeedtestFailed;
use App\Jobs\CheckForInternetConnectionJob;
use App\Models\Result;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Spatie\Ping\PingResult;

beforeEach(function () {
Event::fake();
});

describe('CheckForInternetConnectionJob', function () {
test('batch continues when ping succeeds', function () {
$result = Result::factory()->create(['status' => ResultStatus::Started]);

$successfulPing = PingResult::fromArray(['success' => true, 'host' => 'icanhazip.com']);
app()->bind(PingHostname::class, fn () => new class($successfulPing)
{
public function __construct(private PingResult $ping) {}

public function handle(?string $hostname = null, int $count = 1): ?PingResult
{
return $this->ping;
}
});

[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
$job->handle();

$this->assertFalse($batch->cancelled());
$result->refresh();
expect($result->status)->toBe(ResultStatus::Checking);
Event::assertNotDispatched(SpeedtestFailed::class);
});

test('batch continues when ping fails but HTTP fallback succeeds', function () {
$result = Result::factory()->create(['status' => ResultStatus::Started]);

$failedPing = PingResult::fromArray([
'success' => false,
'error' => 'hostUnreachable',
'host' => 'icanhazip.com',
]);
app()->bind(PingHostname::class, fn () => new class($failedPing)
{
public function __construct(private PingResult $ping) {}

public function handle(?string $hostname = null, int $count = 1): ?PingResult
{
return $this->ping;
}
});

Http::fake([
'*' => Http::response('1.2.3.4', 200),
]);

[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
$job->handle();

$this->assertFalse($batch->cancelled());
$result->refresh();
expect($result->status)->toBe(ResultStatus::Checking);
Event::assertNotDispatched(SpeedtestFailed::class);
});

test('batch continues when ping is unavailable but HTTP fallback succeeds', function () {
$result = Result::factory()->create(['status' => ResultStatus::Started]);

app()->bind(PingHostname::class, fn () => new class
{
public function handle(?string $hostname = null, int $count = 1): ?PingResult
{
return null;
}
});

Http::fake([
'*' => Http::response('1.2.3.4', 200),
]);

[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
$job->handle();

$this->assertFalse($batch->cancelled());
$result->refresh();
expect($result->status)->toBe(ResultStatus::Checking);
Event::assertNotDispatched(SpeedtestFailed::class);
});

test('batch is cancelled when ping fails and HTTP fallback also fails', function () {
$result = Result::factory()->create(['status' => ResultStatus::Started]);

$failedPing = PingResult::fromArray([
'success' => false,
'error' => 'hostUnreachable',
'host' => 'icanhazip.com',
]);
app()->bind(PingHostname::class, fn () => new class($failedPing)
{
public function __construct(private PingResult $ping) {}

public function handle(?string $hostname = null, int $count = 1): ?PingResult
{
return $this->ping;
}
});

Http::fake([
'*' => Http::response('Service Unavailable', 503),
]);

[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
$job->handle();

$this->assertTrue($batch->cancelled());
$result->refresh();
expect($result->status)->toBe(ResultStatus::Failed);
expect($result->data['level'])->toBe('error');
expect($result->data['message'])->toContain('HTTP fallback also failed');
Event::assertDispatched(SpeedtestFailed::class);
});

test('batch is cancelled when ping is unavailable and HTTP fallback throws', function () {
$result = Result::factory()->create(['status' => ResultStatus::Started]);

app()->bind(PingHostname::class, fn () => new class
{
public function handle(?string $hostname = null, int $count = 1): ?PingResult
{
return null;
}
});

Http::fake([
'*' => Http::failedConnection(),
]);

[$job, $batch] = (new CheckForInternetConnectionJob($result))->withFakeBatch();
$job->handle();

$this->assertTrue($batch->cancelled());
$result->refresh();
expect($result->status)->toBe(ResultStatus::Failed);
expect($result->data['message'])->toBe('Ping command is unavailable and HTTP fallback also failed.');
Event::assertDispatched(SpeedtestFailed::class);
});
});