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')); -});