From 9801327321b0ced8f407bf7e23aea15aaf1c8f24 Mon Sep 17 00:00:00 2001 From: kyledoesdev Date: Mon, 19 Jan 2026 12:46:32 -0500 Subject: [PATCH] Refactors timezone detection using TimezoneService Improves timezone detection by introducing a dedicated TimezoneService. This service handles IP-based timezone retrieval and sanitization, including handling local development environments and API failures gracefully. It utilizes the once() helper for caching, ensuring efficiency. The timezone() helper function now leverages the TimezoneService, simplifying its logic. Also includes a fix for the 'Europe/Kiev' timezone. --- src/Libraries/helpers.php | 28 ++---- src/Services/TimezoneService.php | 49 ++++++++++ tests/Feature/CarbonMacroTest.php | 30 +++--- tests/Feature/MakeActionCommandTest.php | 66 ++++++------- tests/Feature/TimezoneServiceTest.php | 121 ++++++++++++++++++++++++ tests/Feature/TimezoneTest.php | 25 ----- 6 files changed, 227 insertions(+), 92 deletions(-) create mode 100644 src/Services/TimezoneService.php create mode 100644 tests/Feature/TimezoneServiceTest.php delete mode 100644 tests/Feature/TimezoneTest.php diff --git a/src/Libraries/helpers.php b/src/Libraries/helpers.php index 786b9f9..e94e673 100644 --- a/src/Libraries/helpers.php +++ b/src/Libraries/helpers.php @@ -1,7 +1,8 @@ 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()); } } @@ -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() + ) ?? []; } } diff --git a/src/Services/TimezoneService.php b/src/Services/TimezoneService.php new file mode 100644 index 0000000..5e8bf3a --- /dev/null +++ b/src/Services/TimezoneService.php @@ -0,0 +1,49 @@ +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'); + } +} \ No newline at end of file diff --git a/tests/Feature/CarbonMacroTest.php b/tests/Feature/CarbonMacroTest.php index f5d64f5..49d5f0c 100644 --- a/tests/Feature/CarbonMacroTest.php +++ b/tests/Feature/CarbonMacroTest.php @@ -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')); + }); }); diff --git a/tests/Feature/MakeActionCommandTest.php b/tests/Feature/MakeActionCommandTest.php index ae1f99a..6d9695c 100644 --- a/tests/Feature/MakeActionCommandTest.php +++ b/tests/Feature/MakeActionCommandTest.php @@ -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 { diff --git a/tests/Feature/TimezoneServiceTest.php b/tests/Feature/TimezoneServiceTest.php new file mode 100644 index 0000000..b08e67a --- /dev/null +++ b/tests/Feature/TimezoneServiceTest.php @@ -0,0 +1,121 @@ + 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); + }); +}); \ No newline at end of file diff --git a/tests/Feature/TimezoneTest.php b/tests/Feature/TimezoneTest.php deleted file mode 100644 index ed33fc9..0000000 --- a/tests/Feature/TimezoneTest.php +++ /dev/null @@ -1,25 +0,0 @@ - Http::response([ - 'timezone' => 'Europe/London', - ], 200), - ]); - - expect(timezone())->toBe('Europe/London'); -}); - -test('correct timezone returned when geolocation service fails', function () { - app()['env'] = 'production'; - - Http::fake([ - 'http://ip-api.com/json/*' => Http::response(null, 500), - ]); - - expect(timezone())->toBe(config('app.timezone')); -});