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
28 changes: 7 additions & 21 deletions src/Libraries/helpers.php
Original file line number Diff line number Diff line change
@@ -1,27 +1,16 @@
<?php

use Illuminate\Support\Str;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Kyledoesdev\Essentials\Services\TimezoneService;

if (! function_exists('timezone')) {
/**
* Get the timezone based on the current request's IP address.
*/
function timezone(): string
{
$tz = config('app.timezone', 'UTC');

if (in_array(app()->environment(), config('essentials.timezone.local_envs'))) {
return $tz;
}

$response = Http::timeout(3)
->retry(1, 200)
->get('http://ip-api.com/json/'.request()->ip());

return $response->successful()
? $response->json('timezone', $tz)
: $tz;
return once(fn () => app(TimezoneService::class)->detect());
}
}

Expand All @@ -31,13 +20,10 @@ function timezone(): string
*/
function zuck(): array
{
$response = Http::timeout(3)
->retry(1, 200)
->get('http://ip-api.com/json/'.request()->ip());

return $response->successful()
? $response->json()
: [];
return rescue(fn () => Http::timeout(3)
->get("http://ip-api.com/json/". request()->ip())
->json()
) ?? [];
}
}

Expand Down
49 changes: 49 additions & 0 deletions src/Services/TimezoneService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace Kyledoesdev\Essentials\Services;

use Illuminate\Support\Facades\Http;

final class TimezoneService
{
public function detect(): string
{
$ip = request()->ip();

if (!$ip || $this->inDevEnv($ip)) {
return $this->default();
}

return $this->fetchTimezone($ip);
}

private function fetchTimezone(string $ip): string
{
$tz = rescue(fn () => Http::timeout(3)
->get("http://ip-api.com/json/{$ip}")
->json('timezone')
);

return $tz ? $this->sanitize($tz) : $this->default();
}

private function sanitize(string $timezone): string
{
return match ($timezone) {
'Europe/Kiev' => 'Europe/Kyiv',
default => $timezone,
};
}

private function inDevEnv(string $ip): bool
{
return
in_array(app()->environment(), config('essentials.timezone.local_envs', [])) ||
in_array($ip, ['127.0.0.1', '::1']);
}

private function default(): string
{
return config('app.timezone', 'UTC');
}
}
30 changes: 16 additions & 14 deletions tests/Feature/CarbonMacroTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,26 @@
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;

test('carbon macro inUserTimezone uses user timezone', function () {
$user = (object) ['timezone' => 'America/New_York'];
describe('Carbon Macro Helpers', function() {
test('inUserTimezone uses user timezone', function () {
$user = (object) ['timezone' => 'America/New_York'];

Auth::shouldReceive('user')
->once()
->andReturn($user);
Auth::shouldReceive('user')
->once()
->andReturn($user);

$date = Carbon::parse('2025-01-01 12:00:00')->inUserTimezone();
$date = Carbon::parse('2025-01-01 12:00:00')->inUserTimezone();

expect($date->tzName)->toBe('America/New_York');
});
expect($date->tzName)->toBe('America/New_York');
});

test('carbon macro inUserTimezone falls back to default timezone when no user is authenticated', function () {
Auth::shouldReceive('user')
->once()
->andReturn(null);
test('inUserTimezone falls back to default timezone when no user is authenticated', function () {
Auth::shouldReceive('user')
->once()
->andReturn(null);

$date = Carbon::parse('2025-01-01 12:00:00')->inUserTimezone();
$date = Carbon::parse('2025-01-01 12:00:00')->inUserTimezone();

expect($date->tzName)->toBe(config('app.timezone'));
expect($date->tzName)->toBe(config('app.timezone'));
});
});
66 changes: 34 additions & 32 deletions tests/Feature/MakeActionCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,49 +7,51 @@
beforeEach(fn () => cleanup());
afterEach(fn () => cleanup());

test('creates a new action file', function (): void {
$actionName = 'CreateUserAction';
$exitCode = Artisan::call('make:action', ['name' => $actionName]);
describe('Action Command', function() {
test('creates a new action file', function (): void {
$actionName = 'CreateUserAction';
$exitCode = Artisan::call('make:action', ['name' => $actionName]);

expect($exitCode)->toBe(Command::SUCCESS);
expect($exitCode)->toBe(Command::SUCCESS);

$expectedPath = app_path('Actions/'.$actionName.'.php');
expect(File::exists($expectedPath))->toBeTrue();
$expectedPath = app_path('Actions/'.$actionName.'.php');
expect(File::exists($expectedPath))->toBeTrue();

$content = File::get($expectedPath);
$content = File::get($expectedPath);

expect($content)
->toContain('namespace App\Actions;')
->toContain('class '.$actionName)
->toContain('public function handle(): void');
});
expect($content)
->toContain('namespace App\Actions;')
->toContain('class '.$actionName)
->toContain('public function handle(): void');
});

test('fails when the action already exists', function (): void {
$actionName = 'CreateUserAction';
Artisan::call('make:action', ['name' => $actionName]);
$exitCode = Artisan::call('make:action', ['name' => $actionName]);
test('fails when the action already exists', function (): void {
$actionName = 'CreateUserAction';
Artisan::call('make:action', ['name' => $actionName]);
$exitCode = Artisan::call('make:action', ['name' => $actionName]);

expect($exitCode)->toBe(Command::FAILURE);
});
expect($exitCode)->toBe(Command::FAILURE);
});

test('add suffix "Action" to action name if not provided', function (string $actionName): void {
$exitCode = Artisan::call('make:action', ['name' => $actionName]);
test('add suffix "Action" to action name if not provided', function (string $actionName): void {
$exitCode = Artisan::call('make:action', ['name' => $actionName]);

expect($exitCode)->toBe(Command::SUCCESS);
expect($exitCode)->toBe(Command::SUCCESS);

$expectedPath = app_path('Actions/CreateUserAction.php');
expect(File::exists($expectedPath))->toBeTrue();
$expectedPath = app_path('Actions/CreateUserAction.php');
expect(File::exists($expectedPath))->toBeTrue();

$content = File::get($expectedPath);
$content = File::get($expectedPath);

expect($content)
->toContain('namespace App\Actions;')
->toContain('class CreateUserAction')
->toContain('public function handle(): void');
})->with([
'CreateUser',
'CreateUser.php',
]);
expect($content)
->toContain('namespace App\Actions;')
->toContain('class CreateUserAction')
->toContain('public function handle(): void');
})->with([
'CreateUser',
'CreateUser.php',
]);
});

function cleanup(): void
{
Expand Down
121 changes: 121 additions & 0 deletions tests/Feature/TimezoneServiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

use Illuminate\Support\Facades\Http;
use Kyledoesdev\Essentials\Services\TimezoneService;

beforeEach(fn () => Http::preventStrayRequests());

describe('TimezoneService', function () {
it('returns default timezone when ip is null', function () {
request()->server->set('REMOTE_ADDR', null);

$service = new TimezoneService();

expect($service->detect())->toBe(config('app.timezone', 'UTC'));
});

it('returns default timezone for localhost ipv4', function () {
request()->server->set('REMOTE_ADDR', '127.0.0.1');

$service = new TimezoneService();

expect($service->detect())->toBe(config('app.timezone', 'UTC'));
});

it('returns default timezone for localhost ipv6', function () {
request()->server->set('REMOTE_ADDR', '::1');

$service = new TimezoneService();

expect($service->detect())->toBe(config('app.timezone', 'UTC'));
});

it('returns default timezone when in configured local environment', function () {
config(['essentials.timezone.local_envs' => ['testing']]);
request()->server->set('REMOTE_ADDR', '8.8.8.8');

$service = new TimezoneService();

expect($service->detect())->toBe(config('app.timezone', 'UTC'));
});

it('fetches timezone from ip-api for valid ip', function () {
config(['essentials.timezone.local_envs' => []]);
request()->server->set('REMOTE_ADDR', '8.8.8.8');

Http::fake([
'ip-api.com/*' => Http::response(['timezone' => 'America/New_York']),
]);

$service = new TimezoneService();

expect($service->detect())->toBe('America/New_York');
});

it('sanitizes Europe/Kiev to Europe/Kyiv', function () {
config(['essentials.timezone.local_envs' => []]);
request()->server->set('REMOTE_ADDR', '8.8.8.8');

Http::fake([
'ip-api.com/*' => Http::response(['timezone' => 'Europe/Kiev']),
]);

$service = new TimezoneService();

expect($service->detect())->toBe('Europe/Kyiv');
});

it('returns default timezone when api request fails', function () {
config(['essentials.timezone.local_envs' => []]);
request()->server->set('REMOTE_ADDR', '8.8.8.8');

Http::fake([
'ip-api.com/*' => Http::response(null, 500),
]);

$service = new TimezoneService();

expect($service->detect())->toBe(config('app.timezone', 'UTC'));
});

it('returns default timezone when api returns null timezone', function () {
config(['essentials.timezone.local_envs' => []]);
request()->server->set('REMOTE_ADDR', '8.8.8.8');

Http::fake([
'ip-api.com/*' => Http::response(['status' => 'fail']),
]);

$service = new TimezoneService();

expect($service->detect())->toBe(config('app.timezone', 'UTC'));
});
});

describe('Timezone Helper Function', function () {
it('returns timezone from service', function () {
config(['essentials.timezone.local_envs' => []]);
request()->server->set('REMOTE_ADDR', '8.8.8.8');

Http::fake([
'ip-api.com/*' => Http::response(['timezone' => 'America/Chicago']),
]);

expect(timezone())->toBe('America/Chicago');
});

it('caches result via once', function () {
config(['essentials.timezone.local_envs' => []]);
request()->server->set('REMOTE_ADDR', '8.8.8.8');

Http::fake([
'ip-api.com/*' => Http::response(['timezone' => 'America/Denver']),
]);

$first = timezone();
$second = timezone();

expect($first)->toBe($second);
Http::assertSentCount(1);
});
});
25 changes: 0 additions & 25 deletions tests/Feature/TimezoneTest.php

This file was deleted.