diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..1245208 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,54 @@ +name: run-tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest] + php: [8.2, 8.1] + laravel: [9.*, 10.*] + stability: [prefer-lowest, prefer-stable] + include: + - laravel: 9.* + testbench: 7.* + carbon: ^2.63 + - laravel: 10.* + testbench: 8.* + carbon: ^2.63 + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: List Installed Dependencies + run: composer show -D + + - name: Execute tests + run: vendor/bin/pest diff --git a/.gitignore b/.gitignore index 27e8277..dc47f7e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .idea vendor composer.lock +.phpunit.result.cache +build \ No newline at end of file diff --git a/composer.json b/composer.json index 3bf0964..fbe4261 100644 --- a/composer.json +++ b/composer.json @@ -13,30 +13,49 @@ } ], "require": { - "php": ">=7.4", - "laravel/framework": "5.6.*|5.7.*|5.8.*|^6|^7|^8|^9|^10.0", - "torann/geoip": "^3.0", - "nesbot/carbon": "^1.0|^2.0" + "php": "^8.1", + "illuminate/support": "^10.0", + "illuminate/contracts": "^10.0", + "stevebauman/location": "^6.6" + }, + "require-dev": { + "orchestra/testbench": "^8.0", + "phpunit/phpunit": "^9.6", + "pestphp/pest": "^1.22", + "pestphp/pest-plugin-laravel": "^1.4", + "spatie/laravel-ray": "^1.32" }, "autoload": { "psr-4": { - "JamesMills\\LaravelTimezone\\": "src/", - "JamesMills\\LaravelTimezone\\Database\\Seeds\\": "database/seeds/" + "JamesMills\\LaravelTimezone\\": "src/" + }, + "files": ["src/helpers.php"] + }, + "autoload-dev": { + "psr-4": { + "JamesMills\\LaravelTimezone\\Tests\\": "tests" } }, + "scripts": { + "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage", + "format": "vendor/bin/pint" + }, "extra": { "laravel": { "providers": [ "JamesMills\\LaravelTimezone\\LaravelTimezoneServiceProvider" - ] + ], + "aliases": { + "Timezone": "JamesMills\\LaravelTimezone\\Facades\\Timezone" + } } }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^2.12|^3.14" - }, - "scripts": { - "pre-package-install": [ - "@php artisan config:clear" - ] + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..0e5919e --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,33 @@ + + + + + tests + + + + + ./src + + + + + + + + + + + diff --git a/src/Events/TimezoneSet.php b/src/Events/TimezoneSet.php new file mode 100644 index 0000000..30c6b2b --- /dev/null +++ b/src/Events/TimezoneSet.php @@ -0,0 +1,15 @@ +publishes([ - __DIR__ . '/database/migrations/add_timezone_column_to_users_table.php.stub' => database_path('/migrations/' . date('Y_m_d_His') . '_add_timezone_column_to_users_table.php'), - ], 'migrations'); - } - - // Register the Timezone alias - AliasLoader::getInstance()->alias('Timezone', \JamesMills\LaravelTimezone\Facades\Timezone::class); + $this->publishes([ + __DIR__ . '/database/migrations/add_timezone_column_to_users_table.php.stub' => database_path('/migrations/' . date('Y_m_d_His') . '_add_timezone_column_to_users_table.php'), + ], 'migrations'); // Register an event listener $this->registerEventListener(); @@ -84,11 +77,10 @@ public function register() */ private function registerEventListener(): void { - $events = [ - \Illuminate\Auth\Events\Login::class, - \Laravel\Passport\Events\AccessTokenCreated::class, - ]; + $events = config('timezone.events'); - Event::listen($events, UpdateUsersTimezone::class); + if (!empty($events)) { + Event::listen($events, UpdateUsersTimezone::class); + } } } diff --git a/src/Listeners/Auth/UpdateUsersTimezone.php b/src/Listeners/Auth/UpdateUsersTimezone.php index f403c6b..e3d52aa 100644 --- a/src/Listeners/Auth/UpdateUsersTimezone.php +++ b/src/Listeners/Auth/UpdateUsersTimezone.php @@ -2,149 +2,34 @@ namespace JamesMills\LaravelTimezone\Listeners\Auth; -use Illuminate\Auth\Events\Login; use Illuminate\Support\Facades\Auth; -use Laravel\Passport\Events\AccessTokenCreated; -use Torann\GeoIP\Location; +use JamesMills\LaravelTimezone\Events\TimezoneSet; +use Stevebauman\Location\Facades\Location; class UpdateUsersTimezone { /** * Handle the event. * + * @param $event * @return void */ - public function handle($event) + public function handle($event): void { - $user = null; - - /** - * If the event is AccessTokenCreated, - * we logged the user and return, - * stopping the execution. - * - * The Auth::loginUsingId dispatches a Login event, - * making this listener be called again. - */ - if ($event instanceof AccessTokenCreated) { - Auth::loginUsingId($event->userId); - - return; - } - - /** - * If the event is Login, we get the user from the web guard. - */ - if ($event instanceof Login) { - $user = Auth::user(); - } - - /** - * If no user is found, we just return. Nothing to do here. - */ - if (is_null($user)) { + if (empty(config('timezone.events'))) { return; } - $ip = $this->getFromLookup(); - $geoip_info = geoip()->getLocation($ip); + $user = $event->user ?? Auth::user(); - if ($user->timezone != $geoip_info['timezone']) { - if (config('timezone.overwrite') == true || $user->timezone == null) { - $user->timezone = $geoip_info['timezone'] ?? $geoip_info->time_zone['name']; - $user->save(); + if ( + (!$user->timezone || config('timezone.overwrite')) && + $position = Location::get() + ) { + $user->timezone = $position->timezone; + $user->save(); - $this->notify($geoip_info); - } + event(new TimezoneSet($user->timezone)); } } - - /** - * @param Location $geoip_info - */ - private function notify(Location $geoip_info) - { - if (request()->hasSession() && config('timezone.flash') == 'off') { - return; - } - - $message = sprintf(config('timezone.message', 'We have set your timezone to %s'), $geoip_info['timezone']); - - if (config('timezone.flash') == 'laravel') { - request()->session()->flash('success', $message); - - return; - } - - if (config('timezone.flash') == 'laracasts') { - flash()->success($message); - - return; - } - - if (config('timezone.flash') == 'mercuryseries') { - flashy()->success($message); - - return; - } - - if (config('timezone.flash') == 'spatie') { - flash()->success($message); - - return; - } - - if (config('timezone.flash') == 'mckenziearts') { - notify()->success($message); - - return; - } - - if (config('timezone.flash') == 'tall-toasts') { - toast()->success($message)->pushOnNextPage(); - - return; - } - } - - /** - * @return mixed - */ - private function getFromLookup() - { - $result = null; - - foreach (config('timezone.lookup') as $type => $keys) { - if (empty($keys)) { - continue; - } - - $result = $this->lookup($type, $keys); - - if (is_null($result)) { - continue; - } - } - - return $result; - } - - /** - * @param $type - * @param $keys - * @return string|null - */ - private function lookup($type, $keys) - { - $value = null; - - foreach ($keys as $key) { - if (! request()->$type->has($key)) { - continue; - } - $value = request()->$type->get($key); - } - - return $value; - } } diff --git a/src/Timezone.php b/src/Timezone.php index 6d9d6b4..6c34a00 100644 --- a/src/Timezone.php +++ b/src/Timezone.php @@ -3,62 +3,104 @@ namespace JamesMills\LaravelTimezone; use Carbon\Carbon; +use Carbon\CarbonImmutable; +use Carbon\FactoryImmutable; +use Illuminate\Support\Facades\Auth; class Timezone { /** - * @param Carbon\Carbon|null $date - * @param null $format - * @param bool $format_timezone + * Gets the current user's timezone and if not present, + * the app's timezone as a fallback. + * * @return string */ - public function convertToLocal(?Carbon $date, $format = null, $format_timezone = false, $enableTranslation = null) : string + public function getCurrentTimezone(): string { - if (is_null($date)) { - return __('Empty'); - } + return Auth::user()?->timezone ?? config('app.timezone'); + } - $timezone = (auth()->user()->timezone) ?? config('app.timezone'); - - $enableTranslation = $enableTranslation !== null ? $enableTranslation : config('timezone.enableTranslation'); - - $date->setTimezone($timezone); + public function getCarbonFactory(object $user = null): FactoryImmutable + { + $factory = (new FactoryImmutable([ + 'locale' => config('app.locale'), + 'timezone' => config('app.timezone'), + ])) + ->setClassName(CarbonImmutable::class); - if (is_null($format)) { - return $enableTranslation ? $date->translatedFormat(config('timezone.format')) : $date->format(config('timezone.format')); + if ($user = $user ?? Auth::user()) { + $factory->mergeSettings([ + 'locale' => $user->locale ?? config('app.locale'), + 'timezone' => $user->timezone ?? config('app.timezone'), + ]); } - $formatted_date_time = $enableTranslation ? $date->translatedFormat($format) : $date->format($format); + return $factory; + } + + /** + * Converts a date to the current user's timezone. + * Optionally pass a format to format the date, otherwise + * returns the updated CarbonImmutable instance. + * + * @param Carbon|CarbonImmutable|null $date + * @param string|null $format + * @return string|CarbonImmutable + */ + public function convertToLocal(null|Carbon|CarbonImmutable $date, string $format = null): string|CarbonImmutable + { + $date = $date ?? now(); - if ($format_timezone) { - return $formatted_date_time . ' ' . $this->formatTimezone($date); + $converted = $this->getCarbonFactory() + ->make($date); + + if (!$format) { + return $converted; } - return $formatted_date_time; + return $converted->format($format); + } + + public function formatLocal(null|Carbon|CarbonImmutable $date, string $format = null): string + { + $date = $date ?? now(); + + return $this->convertToLocal($date, $format ?? config('timezone.format')); } /** - * @param $date - * @return Carbon\Carbon + * Parses a date from the local user's timezone + * and converts it to the app's timezone for storage. + * + * @param mixed $date + * @return CarbonImmutable */ - public function convertFromLocal($date) : Carbon + public function convertFromLocal(mixed $date): CarbonImmutable { - return Carbon::parse($date, auth()->user()->timezone)->setTimezone('UTC'); + return $this->getCarbonFactory() + ->parse($date, $this->getCurrentTimezone()) + ->setTimezone(config('app.timezone')); } /** - * @param Carbon\Carbon $date - * @return string + * Gets the user's 'today' date object. + * + * @return CarbonImmutable */ - private function formatTimezone(Carbon $date) : string + public function today(): CarbonImmutable { - $timezone = $date->format('e'); - $parts = explode('/', $timezone); - - if (count($parts) > 1) { - return str_replace('_', ' ', $parts[1]) . ', ' . $parts[0]; - } + return $this->getCarbonFactory() + ->today(); + } - return str_replace('_', ' ', $parts[0]); + /** + * Gets the user's 'now' date object. + * + * @return CarbonImmutable + */ + public function now(): CarbonImmutable + { + return $this->getCarbonFactory() + ->now(); } } diff --git a/src/Traits/HasCarbonFactory.php b/src/Traits/HasCarbonFactory.php new file mode 100644 index 0000000..6b91171 --- /dev/null +++ b/src/Traits/HasCarbonFactory.php @@ -0,0 +1,27 @@ +getCarbonFactory() + ->now(); + } + + public function today(): CarbonImmutable + { + return $this->getCarbonFactory() + ->today(); + } +} \ No newline at end of file diff --git a/src/config/timezone.php b/src/config/timezone.php index 3bede06..1ff899d 100644 --- a/src/config/timezone.php +++ b/src/config/timezone.php @@ -1,19 +1,19 @@ 'laravel', + 'events' => [ + \Illuminate\Auth\Events\Login::class, + ], /* |-------------------------------------------------------------------------- @@ -22,64 +22,19 @@ | | Here you may configure if you would like to overwrite existing | timezones if they have been already set in the database. - | options [true, false] | */ - 'overwrite' => true, + 'overwrite' => false, /* |-------------------------------------------------------------------------- | Overwrite Default Format |-------------------------------------------------------------------------- | - | Here you may configure if you would like to overwrite the - | default format. - | - */ - - 'format' => 'jS F Y g:i:a', - - /* - |-------------------------------------------------------------------------- - | Enable translated output - |-------------------------------------------------------------------------- - | - | Here you may configure if you would like to use translated output. - | - */ - - 'enableTranslation' => false, - - /* - |-------------------------------------------------------------------------- - | Lookup Array - |-------------------------------------------------------------------------- - | - | Here you may configure the lookup array whom it will be used to fetch the user remote address. - | When a key is found inside the lookup array that key it will be used. - | - */ - - 'lookup' => [ - 'server' => [ - 'REMOTE_ADDR', - ], - 'headers' => [ - - ], - ], - - /* - |-------------------------------------------------------------------------- - | User Message - |-------------------------------------------------------------------------- - | - | Here you may configure the message shown to the user when the timezone is set. - | Be sure to include the %s which will be replaced by the detected timezone. - | e.g. We have set your timezone to America/New_York + | Set the default format for displaying dates. | */ - 'message' => 'We have set your timezone to %s', + 'format' => 'F j, Y g:ia', ]; diff --git a/src/database/migrations/add_timezone_column_to_users_table.php.stub b/src/database/migrations/add_timezone_column_to_users_table.php.stub index 9799032..2a1e81d 100755 --- a/src/database/migrations/add_timezone_column_to_users_table.php.stub +++ b/src/database/migrations/add_timezone_column_to_users_table.php.stub @@ -3,7 +3,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; -class AddTimezoneColumnToUsersTable extends Migration +return new class extends Migration { /** * Run the migrations. @@ -12,11 +12,10 @@ class AddTimezoneColumnToUsersTable extends Migration */ public function up() { - if (!Schema::hasColumn('users', 'timezone')) { - Schema::table('users', function (Blueprint $table) { - $table->string('timezone')->after('remember_token')->nullable(); - }); - } + Schema::table('users', function (Blueprint $table) { + $table->string('timezone')->after('remember_token')->nullable(); + $table->string('locale')->after('remember_token')->nullable(); + }); } /** @@ -30,4 +29,4 @@ class AddTimezoneColumnToUsersTable extends Migration $table->dropColumn('timezone'); }); } -} +}; diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..1c69594 --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,55 @@ +toEqual($timezone); +})->with([ + 'a user that has a timezone' => fn () => logIn()->timezone, + 'a user without a timezone' => function () { + logIn(['timezone' => null]); + + return 'UTC'; + }, +]); + +it('can convert date to local time object', function () { + $user = logIn(); + $date = CarbonImmutable::create(2000, 4, 1, 15); + $expected = $date->setTimezone($user->timezone); + + expect($expected)->toEqual(Timezone::convertToLocal($date)) + ->and($expected)->toEqual(to_local_timezone($date)); +}); + +it('can convert date to local time formatted', function (?string $format) { + $user = logIn(); + $date = CarbonImmutable::create(2000, 4, 1, 15); + $expected = $date->setTimezone($user->timezone); + $format = $format ?? config('timezone.format'); + + expect($expected->format($format))->toEqual(Timezone::formatLocal($date, $format)); +})->with([ + 'using default format' => null, + 'just days' => 'Y-m-d', + 'day and time' => 'Y-m-d g:ia', +]); + +it('can convert from local timezone', function () { + $user = logIn(['timezone' => 'Asia/Shanghai']); + $userDate = Carbon::now($user->timezone); + $converted = from_local_timezone($userDate); + ray($userDate, $converted); + + expect($userDate->toDateTimeString()) + ->not()->toEqual($converted->toDateTimeString()); +}); + +it('can make local today', function () { + $user = logIn(); + $date = today($user->timezone); + + expect($date)->toEqual(local_today()); +}); + +it('can make local now', function () { + $user = logIn(); + $date = now($user->timezone) + ->startOfMinute(); + + expect($date)->toEqual(local_now()->startOfMinute()); +}); diff --git a/tests/Models/User.php b/tests/Models/User.php new file mode 100644 index 0000000..4950d0f --- /dev/null +++ b/tests/Models/User.php @@ -0,0 +1,12 @@ +in(__DIR__); + +function seedUser(array $attributes = []): User +{ + return User::create([ + 'name' => fake()->name(), + 'email' => fake()->email(), + 'password' => bcrypt('secret'), + 'timezone' => fake()->timezone(), + 'locale' => fake()->locale(), + ...$attributes, + ]); +} + +function logIn(array $attributes = []): User +{ + test()->user = seedUser($attributes); + test()->be(test()->user); + + return test()->user; +} diff --git a/tests/SetTimezoneTest.php b/tests/SetTimezoneTest.php new file mode 100644 index 0000000..5184754 --- /dev/null +++ b/tests/SetTimezoneTest.php @@ -0,0 +1,64 @@ +user = seedUser(['timezone' => null]); + Event::fake([ + TimezoneSet::class, + ]); +}); + +it('can detect timezone on login with default ip address location', function () { + expect($this->user->timezone)->toBeNull(); + + $this->post('/login', ['id' => $this->user->id]); + + $this->user->refresh(); + expect($this->user->timezone)->toBeString(); + Event::assertDispatched(TimezoneSet::class); +}); + +it('can opt out of detection', function () { + config()->set('timezone.events', false); + expect($this->user->timezone)->toBeNull(); + + $this->post('/login', ['id' => $this->user->id]); + + $this->user->refresh(); + expect($this->user->timezone)->toBeNull(); + Event::assertNotDispatched(TimezoneSet::class); +}); + +it("won't overwrite existing values", function () { + config()->set('timezone.overwrite', false); + $timezone = fake()->timezone(); + $this->user->update(['timezone' => $timezone]); + + $this->user->refresh(); + expect($this->user->timezone)->toEqual($timezone); + + $this->post('/login', ['id' => $this->user->id]); + + $this->user->refresh(); + expect($this->user->timezone)->toEqual($timezone); + Event::assertNotDispatched(TimezoneSet::class); +}); + +it('can overwrite existing values', function () { + config()->set('timezone.overwrite', true); + $timezone = 'Asia/Shanghai'; + $this->user->update(['timezone' => $timezone]); + + $this->user->refresh(); + expect($this->user->timezone)->toEqual($timezone); + + $this->post('/login', ['id' => $this->user->id]); + + $this->user->refresh(); + expect($this->user->timezone)->not()->toEqual($timezone); + Event::assertDispatched(TimezoneSet::class, fn ($event) => $event->timezone === $this->user->timezone); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..40d657e --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,47 @@ +loadLaravelMigrations(); + + $migration = include __DIR__.'/../src/database/migrations/add_timezone_column_to_users_table.php.stub'; + $migration->up(); + } + + public function getEnvironmentSetUp($app): void + { + config()->set('database.default', 'testing'); + } + + public function defineRoutes($router) + { + // This mocks a login to trigger the event + $router->post('/login', function (Request $request) { + $user = User::findOrFail($request->input('id')); + Auth::login($user); + + return response()->json(); + }); + } +}