From 9182c932ede9b398096951ac1ddc3023d432ed13 Mon Sep 17 00:00:00 2001 From: riccardodallavia Date: Thu, 9 Nov 2023 12:45:12 +0100 Subject: [PATCH] first release --- .editorconfig | 15 + .gitattributes | 19 + .github/ISSUE_TEMPLATE/config.yml | 14 + .github/dependabot.yml | 12 + .github/workflows/dependabot-auto-merge.yml | 32 ++ .../workflows/fix-php-code-style-issues.yml | 21 + .github/workflows/phpstan.yml | 26 ++ .github/workflows/run-tests.yml | 48 +++ .github/workflows/update-changelog.yml | 28 ++ .gitignore | 11 + CHANGELOG.md | 3 + LICENSE.md | 21 + README.md | 390 ++++++++++++++++++ art/socialcard-dark.png | Bin 0 -> 30042 bytes art/socialcard-light.png | Bin 0 -> 29971 bytes composer.json | 78 ++++ config/model-expires.php | 84 ++++ .../create_expirations_table.php.stub | 19 + phpstan-baseline.neon | 0 phpstan.neon.dist | 14 + phpunit.xml.dist | 39 ++ src/Commands/ModelExpiresCheckCommand.php | 70 ++++ src/Commands/ModelExpiresDeleteCommand.php | 70 ++++ src/Events/ExpiredModelsDeleted.php | 16 + src/Events/ModelExpiring.php | 16 + src/HasExpiration.php | 204 +++++++++ .../SendModelExpiringNotification.php | 19 + src/ModelExpiresServiceProvider.php | 42 ++ src/Models/Expiration.php | 25 ++ .../ModelExpiringNotification.php | 33 ++ src/Scopes/ExpirationScope.php | 47 +++ src/Support/Config.php | 71 ++++ tests/HasExpirationTest.php | 181 ++++++++ tests/ModelExpiresCheckCommandTest.php | 128 ++++++ tests/ModelExpiresDeleteCommandTest.php | 101 +++++ tests/Pest.php | 5 + tests/ScopeTest.php | 44 ++ tests/Support/Events/UserDeletedEvent.php | 14 + tests/Support/Factories/TenantFactory.php | 18 + tests/Support/Factories/UserFactory.php | 18 + tests/Support/Models/Tenant.php | 34 ++ tests/Support/Models/User.php | 35 ++ tests/Support/TestCase.php | 44 ++ 43 files changed, 2109 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/dependabot-auto-merge.yml create mode 100644 .github/workflows/fix-php-code-style-issues.yml create mode 100644 .github/workflows/phpstan.yml create mode 100644 .github/workflows/run-tests.yml create mode 100644 .github/workflows/update-changelog.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 art/socialcard-dark.png create mode 100644 art/socialcard-light.png create mode 100644 composer.json create mode 100644 config/model-expires.php create mode 100644 database/migrations/create_expirations_table.php.stub create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 src/Commands/ModelExpiresCheckCommand.php create mode 100644 src/Commands/ModelExpiresDeleteCommand.php create mode 100644 src/Events/ExpiredModelsDeleted.php create mode 100644 src/Events/ModelExpiring.php create mode 100755 src/HasExpiration.php create mode 100644 src/Listeners/SendModelExpiringNotification.php create mode 100644 src/ModelExpiresServiceProvider.php create mode 100644 src/Models/Expiration.php create mode 100644 src/Notifications/ModelExpiringNotification.php create mode 100644 src/Scopes/ExpirationScope.php create mode 100644 src/Support/Config.php create mode 100644 tests/HasExpirationTest.php create mode 100644 tests/ModelExpiresCheckCommandTest.php create mode 100644 tests/ModelExpiresDeleteCommandTest.php create mode 100644 tests/Pest.php create mode 100644 tests/ScopeTest.php create mode 100644 tests/Support/Events/UserDeletedEvent.php create mode 100644 tests/Support/Factories/TenantFactory.php create mode 100644 tests/Support/Factories/UserFactory.php create mode 100644 tests/Support/Models/Tenant.php create mode 100644 tests/Support/Models/User.php create mode 100644 tests/Support/TestCase.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a7c44dd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9e9519b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/art export-ignore +/docs export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.php_cs.dist.php export-ignore +/psalm.xml export-ignore +/psalm.xml.dist export-ignore +/testbench.yaml export-ignore +/UPGRADING.md export-ignore +/phpstan.neon.dist export-ignore +/phpstan-baseline.neon export-ignore diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..05c465e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a question + url: https://github.com/maize-tech/laravel-model-expires/discussions/new?category=q-a + about: Ask the community for help + - name: Request a feature + url: https://github.com/maize-tech/laravel-model-expires/discussions/new?category=ideas + about: Share ideas for new features + - name: Report a security issue + url: https://github.com/maize-tech/laravel-model-expires/security/policy + about: Learn how to notify us for sensitive bugs + - name: Report a bug + url: https://github.com/maize-tech/laravel-model-expires/issues/new + about: Report a reproducible bug diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..30c8a49 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" \ No newline at end of file diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..ca2197d --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,32 @@ +name: dependabot-auto-merge +on: pull_request_target + +permissions: + pull-requests: write + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v1.6.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-merge Dependabot PRs for semver-minor updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Auto-merge Dependabot PRs for semver-patch updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml new file mode 100644 index 0000000..bdef9a9 --- /dev/null +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -0,0 +1,21 @@ +name: Fix PHP code style issues + +on: [push] + +jobs: + php-code-styling: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref }} + + - name: Fix PHP code style issues + uses: aglipanci/laravel-pint-action@2.3.0 + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Fix styling diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..9d41c0c --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,26 @@ +name: PHPStan + +on: + push: + paths: + - '**.php' + - 'phpstan.neon.dist' + +jobs: + phpstan: + name: phpstan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@v2 + + - name: Run PHPStan + run: ./vendor/bin/phpstan --error-format=github diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..986ecac --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,48 @@ +name: run-tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, windows-latest] + php: [8.2, 8.1] + laravel: [10.*] + stability: [prefer-lowest, prefer-stable] + include: + - 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: Execute tests + run: vendor/bin/pest diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000..b20f3b6 --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,28 @@ +name: "Update Changelog" + +on: + release: + types: [released] + +jobs: + update: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: main + + - name: Update Changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + latest-version: ${{ github.event.release.name }} + release-notes: ${{ github.event.release.body }} + + - name: Commit updated CHANGELOG + uses: stefanzweifel/git-auto-commit-action@v4 + with: + branch: main + commit_message: Update CHANGELOG + file_pattern: CHANGELOG.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83c9b9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.idea +.phpunit.result.cache +build +composer.lock +coverage +docs +phpunit.xml +phpstan.neon +testbench.yaml +vendor +node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8595e34 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to `laravel-model-expires` will be documented in this file. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..43c44ab --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2023 MAIZE SRL + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6ba5b5 --- /dev/null +++ b/README.md @@ -0,0 +1,390 @@ +

+ + + + Social Card of Laravel Model Expires + +

+ +# Laravel Model Expires + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/maize-tech/laravel-model-expires.svg?style=flat-square)](https://packagist.org/packages/maize-tech/laravel-model-expires) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/maize-tech/laravel-model-expires/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/maize-tech/laravel-model-expires/actions?query=workflow%3Arun-tests+branch%3Amain) +[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/maize-tech/laravel-model-expires/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/maize-tech/laravel-model-expires/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) +[![Total Downloads](https://img.shields.io/packagist/dt/maize-tech/laravel-model-expires.svg?style=flat-square)](https://packagist.org/packages/maize-tech/laravel-model-expires) + +With this package you can add expiration date to any model and exclude expired models from queries. +When needed, you could send a notification for expiring models. +You can also set a deletion date for every model and automatically clean them up with a command. + +## Installation + +You can install the package via composer: + +```bash +composer require maize-tech/laravel-model-expires +``` + +You can publish the config and migration files and run the migrations with: + +```bash +php artisan model-expires:install +``` + +This is the contents of the published config file: + +```php +return [ + + /* + |-------------------------------------------------------------------------- + | Expiration model + |-------------------------------------------------------------------------- + | + | Here you may specify the fully qualified class name of the expiration model. + | + */ + + 'expiration_model' => Maize\ModelExpires\Models\Expiration::class, + + 'model' => [ + + /* + |-------------------------------------------------------------------------- + | Expires after days + |-------------------------------------------------------------------------- + | + | Here you may specify the default amount of days after which a model + | should expire. + | If null, all newly created models won't have a default expiration date. + | + */ + + 'expires_after_days' => null, + + /* + |-------------------------------------------------------------------------- + | Deletes after days + |-------------------------------------------------------------------------- + | + | Here you may specify the default amount of days after which a model + | should be deleted. + | If null, all newly created models won't have a default deletion date. + | + */ + + 'deletes_after_days' => null, + ], + + 'expiring_notification' => [ + + /* + |-------------------------------------------------------------------------- + | Enable expiring notification + |-------------------------------------------------------------------------- + | + | Here you may specify whether you want to enable model expiring + | notifications or not. + | + */ + + 'enabled' => true, + + /* + |-------------------------------------------------------------------------- + | Notification class + |-------------------------------------------------------------------------- + | + | Here you may specify the fully qualified class name of the default notification. + | If null, no notifications will be sent. + | + */ + + 'notification' => Maize\ModelExpires\Notifications\ModelExpiringNotification::class, + + /* + |-------------------------------------------------------------------------- + | Notifiable emails + |-------------------------------------------------------------------------- + | + | Here you may specify the default list of notifiable email addresses. + | + */ + + 'notifiables' => [ + // + ], + ], +]; +``` + +## Usage + +### Basic + +To use the package, add the `Maize\ModelExpires\HasExpiration` trait to all models you want to have an expiration date: + +``` php +setExpiresAt( + expiresAt: now()->addDays(5), + deletesAt: now()->addDays(10), +); // user will have both an expiration and deletion date + +$user = User::create([])->setExpiresAt( + expiresAt: now()->addDays(5) +); // user will have an expiration date but will not be deleted +``` + +### Checking expiration and deletion days left + +You can also check whether a model is expired and calculate the amount of days before its expiration (or deletion): + +``` php +$user = User::create([])->setExpiresAt( + expiresAt: now()->addDays(5), + deletesAt: now()->addDays(10), +); + +$user->isExpired(); // returns false + +$user->getDaysLeftToExpiration(); // returns 5 +$user->getDaysLeftToDeletion(); // returns 10 + + +$user = User::create([])->setExpiresAt( + expiresAt: now()->subDay() +); + +$user->isExpired(); // returns true + +$user->getDaysLeftToExpiration(); // returns 0, as the model is already expired +$user->getDaysLeftToDeletion(); // returns null, as model does not have a deletion date +``` + +### Excluding expired models + +When you want to exclude expired models, all you have to do is use the `withoutExpired` scope method: + +``` php +$user = User::create([]); // user does not have an expiration date +$expiredUser = User::create([])->setExpiresAt( + expiresAt: now()->subDay(), +); // user is already expired + +User::withoutExpired()->count(); // returns 1, which is the $user model +User::withoutExpired()->get(); // returns the $user model +``` + + +### Retrieving only expired models + +When you want to retrieve expired models, all you have to do is use the `onlyExpired` scope method: + +``` php +$user = User::create([]); // user does not have an expiration date +$expiredUser = User::create([])->setExpiresAt( + expiresAt: now()->subDay(), +); // user is already expired + +User::onlyExpired()->count() // returns 1, which is the $expiredUser model +User::onlyExpired()->get(); // returns the $expiredUser model +``` + +### Default expiration date + +If you wish, you can define a default expiration date. This can be done in two ways. + +First, you can set a value for `expires_after_days` property under `config/model-expires.php` config file. +When set, all models including the `Maize\ModelExpires\HasExpiration` trait will automatically have an expiration date upon its creation: + +``` php +config()->set('model-expires.model.expires_after_days', 5); + +$user = User::create([]); +$user->getDaysLeftToExpiration(); // returns 5 +``` + +The second way is overriding the `defaultExpiresAt` method within all models you want to have a default expiration date: + +``` php +addDays(10); // all user models will expire 10 days after being created + } +} +``` + +``` php +addMonth(); // all tenant models will expire 1 month after being created + } +} +``` + +### Default deletion date + +If you wish, you can define a default deletion date. This can be done in two ways. + +First, you can set a value for `deletes_after_days` property under `config/model-expires.php` config file. +When set, all models including the `Maize\ModelExpires\HasExpiration` trait will automatically have a deletion date upon its creation: + +``` php +config()->set('model-expires.model.deletes_after_days', 5); + +$user = User::create([]); +$user->getDaysLeftToDeletion(); // returns 5 +``` + +The second way is overriding the `defaultDeletesAt` method within all models you want to have a default deletion date: + +``` php +addDays(10); // all user models will be deleted 10 days after being created + } +} +``` + +``` php +addMonth(); // all tenant models will be deleted 1 month after being created + } +} +``` + +### Scheduling expiration check + +The package comes with the `expires:check` command, which automatically fires a `ModelExpiring` event for all expiring models. + +To do so, you should define how often you want to fire the event. +All you have to do is overriding the `fireExpiringEventBeforeDays` for all models using the `HasExpiration` trait: + +``` php +use Illuminate\Database\Eloquent\Factories\HasFactory; + +class User extends Authenticatable +{ + use HasExpiration; + + public static function fireExpiringEventBeforeDays(): array + { + return [5, 10]; // expiring events will be fired 5 and 10 days before each model's expiration + } +} +``` + +By default, the method returns an empty array, meaning models will never fire expiring events. + +Once done, you can schedule the command on a daily basis using the `schedule` method of the console kernel (usually located under the `App\Console` directory): + +``` php +use Maize\ModelExpires\Commands\ModelExpiresDeleteCommand; + +$schedule->command(ModelExpiresCheckCommand::class)->daily(); +``` + +### Scheduling models deletion + +The package also comes with the `expires:delete` command, which automatically deletes all expired and deletable models. +This comes pretty useful when automatizing its execution using Laravel's scheduling. +All you have to do is add the following instruction to the `schedule` method of the console kernel (usually located under the `App\Console` directory): + +``` php +use Maize\ModelExpires\Commands\ModelExpiresDeleteCommand; + +$schedule->command(ModelExpiresDeleteCommand::class)->daily(); +``` + +## Testing + +```bash +composer test +``` + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Contributing + +Please see [CONTRIBUTING](https://github.com/maize-tech/.github/blob/main/CONTRIBUTING.md) for details. + +## Security Vulnerabilities + +Please review [our security policy](https://github.com/maize-tech/.github/security/policy) on how to report security vulnerabilities. + +## Credits + +- [Enrico De Lazzari](https://github.com/enricodelazzari) +- [Riccardo Dalla Via](https://github.com/riccardodallavia) +- [All Contributors](../../contributors) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/art/socialcard-dark.png b/art/socialcard-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..0a7265a97fa0523c7ad5c857684282c66496c077 GIT binary patch literal 30042 zcmYhjbwJbK7e72gT2exg7EwY#QaZ&zKw%rwAt5Ouor)kK;YSI<0fGo@z~~$eqI8WK zjnrtys4;ln@csRs=lO4Y@4oLn_uP}ObMB%I^>we(aMFN4psSA_YCi{o$V)&VQWGi) z;K`e1UQQ6moBxrvhOr;XCNVV8+&5S_AT;pi#JJyY(blc%;k-*HtAD7(#YsdMaqL*@VPL*w{^ z7W&#ZFBM7Dd4RV;){mAdn=%wTK)#?oW@~^LJS_Bh3i|PMK?H{c{m~7(0s@VILv&W) z$ls4Uhh3nr_{sTrN!UOjL+J7QzTPHgOOgSqi|-{5kF%}%AbcCw$fih~K7eRJAQRX9 zxPmVso|VT?&_Ce|I@wI6GrH`?^7Mx^sni9@O+cV#(j{6Yd4fng=8e!Jk-IMQQpPx zIhVhGmtDLA3-!nTkuL3eYBsq_rM#rgmK1JQ;pQ(dg_&|4Z+_XeH7{C`$ybKdAJ zFuQw)C_|!Ne!&6BPY)i{V^kF`_U3=NuI3Ug^6~V`FeOkC z#JDjla5LOS4E7K-Aa=nby7biGN(W`{VMHY(=>X~Fqi=gUP2cNY6~0v_kcK(@Z^v)F z@GYcUMCQ){^bq zVUWvLI6i$kczFh{X=n5Wnclo)?f~LI1LQ<``Fc2QB?GBs&E>1)0ccePh~j5Z)`cR0 zZrA-EiHZ0h$&W8cz5-P}=gqUc{^UZ?U>7vTxC25By~}ey$?EU$Zvb*?env2cjb2cg^(R2#T;x;I|2MkiO#+6p)7YA1fa3CP z_~*fl{-z5JQMx$ktgJ3}%Op@1)dkUp#_YugAVc#14dkh2hF@2>Cjt&mYFqQ;-4eza6x7d`I!wDo|jbnLesPx6mz|pd$rq(FC zO}_P>MET!~gVO(lGHZ;!BcmK9Dm9h3rh*%$js>X3t35)UM}hgv!rU6eDH-g?9PgvE zZj;7%J9;eEd9Od`VVao^Sl=wLSdi1xx6G8YD1W8lRQWYL2~%}(VCQF@DOaCWxi`ET zT-ux=8Wzvx&iJ_fJH)tQst&~!-GBov@3d6XcB-`Rj`OOn-%HY+V%Qy@uHN?KzY7Y^ zIV}tQ4$8_WH+i`*r}F+iigE|c1ag}R(mG&m>3XgKF|Kf}$0uLO+ng@1&IO5TCQo)x z`Ogx)$qJnxewI%)dt9&iWqj1kW9C26iZqY5iA14dUDqa9(xyd!a*d(|J!$z&0z42F(mhxAkP;-0Q2WNgYst>d|l6X$`S%Wt8jF0Q|H>x zJV&d~L-1{3z{FfP(YpH{rX)hS%VZ+s#iwTV!DKdQgUeggAwbC8$~Wm9tw{^{*;+bzgNY17qeW)Vm2_4a-ld}7Q&0i|!S|Ae(L{bU}!fxTOZt~33@;$e2$Z3iYGp26l_33z@XQtbDO{2RyGg{3vvjWk9RK&R2 z^#*f+O{UW#&|6qU5)V#l4BOnIHF2Kz>z7Eir;U@WoLrj4gPu3_0mEGDGYq?av7OTd zk9rmEVr5@YmP_^toB_&5XV71KX=+9pW5~9b=>G{SPc*EHIy&AGxA1=svr4KgcdHkA z>fUJ6(l7Y-7#0|+L|a;SN2Dp41_}RRE_+ikVE;JMfnAnms=eyPT~ti-&G&grk^TMW zFDeSBE0%t}?O?AkoK%51%#JXU>K&^N@jW9}#9 zgTgLYLcKPy(_&3LeKvXW2j}QWq{)|bVI>}Gv`e&W)rm+|f5TyLuO%pA zW8)=N_mqGi&y+)KRl#{Zm5=Yz^)ATC)fZt^mJt9gz|e*J=s~&DUy$kA`n1i!t4;5w9 zSXNI51e�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 Bew_dS literal 0 HcmV?d00001 diff --git a/art/socialcard-light.png b/art/socialcard-light.png new file mode 100644 index 0000000000000000000000000000000000000000..4e9c300e61a90f6e7a07a50ce925b687815ecc8a GIT binary patch literal 29971 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 wsr2&LZ=!97^jZ6B?+h*p%v|#ZOjphnd}vbNPf< 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 Maize\ModelExpires\Models\Expiration::class, + + 'model' => [ + + /* + |-------------------------------------------------------------------------- + | Expires after days + |-------------------------------------------------------------------------- + | + | Here you may specify the default amount of days after which a model + | should expire. + | If null, all newly created models won't have a default expiration date. + | + */ + + 'expires_after_days' => null, + + /* + |-------------------------------------------------------------------------- + | Deletes after days + |-------------------------------------------------------------------------- + | + | Here you may specify the default amount of days after which a model + | should be deleted. + | If null, all newly created models won't have a default deletion date. + | + */ + + 'deletes_after_days' => null, + ], + + 'expiring_notification' => [ + + /* + |-------------------------------------------------------------------------- + | Enable expiring notification + |-------------------------------------------------------------------------- + | + | Here you may specify whether you want to enable model expiring + | notifications or not. + | + */ + + 'enabled' => true, + + /* + |-------------------------------------------------------------------------- + | Notification class + |-------------------------------------------------------------------------- + | + | Here you may specify the fully qualified class name of the default notification. + | If null, no notifications will be sent. + | + */ + + 'notification' => Maize\ModelExpires\Notifications\ModelExpiringNotification::class, + + /* + |-------------------------------------------------------------------------- + | Notifiable emails + |-------------------------------------------------------------------------- + | + | Here you may specify the default list of notifiable email addresses. + | + */ + + 'notifiables' => [ + // + ], + ], +]; diff --git a/database/migrations/create_expirations_table.php.stub b/database/migrations/create_expirations_table.php.stub new file mode 100644 index 0000000..a21152a --- /dev/null +++ b/database/migrations/create_expirations_table.php.stub @@ -0,0 +1,19 @@ +id(); + $table->morphs('model'); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('deletes_at')->nullable(); + $table->timestamps(); + }); + } +}; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..e69de29 diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..a91953b --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,14 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 4 + paths: + - src + - config + - database + tmpDir: build/phpstan + checkOctaneCompatibility: true + checkModelProperties: true + checkMissingIterableValueType: false + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..290f954 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,39 @@ + + + + + tests + + + + + ./src + + + + + + + + + + + diff --git a/src/Commands/ModelExpiresCheckCommand.php b/src/Commands/ModelExpiresCheckCommand.php new file mode 100644 index 0000000..c14a20d --- /dev/null +++ b/src/Commands/ModelExpiresCheckCommand.php @@ -0,0 +1,70 @@ +option('chunk'); + + $models = $this->models(); + + if ($models->isEmpty()) { + $this->info('No expiring models found.'); + + return self::FAILURE; + } + + $models->each(function ($model) use ($chunkSize) { + $total = 0; + + $query = $model::query(); + + collect( + $model::fireExpiringEventBeforeDays() + )->each( + fn ($days) => $query->orWhereRelation('expiration', 'expires_at', '=', now()->addDays($days)->startOfDay()) + ); + + $query->chunkById($chunkSize, function (Collection $models) use (&$total) { + $models->each( + fn (Model $model) => ModelExpiring::dispatch($model) + ); + + $total += $models->count(); + }); + + $this->line("{$model}: {$total} expiring"); + }); + + $this->comment('All done'); + + return self::SUCCESS; + } + + protected function models(): Collection + { + return Config::getExpirationModel() + ->newQuery() + ->groupBy('model_type') + ->pluck('model_type') + ->map(fn ($alias) => Relation::getMorphedModel($alias)) + ->filter(fn ($model) => class_exists($model)) + ->filter(fn ($model) => in_array(HasExpiration::class, class_uses_recursive($model))) + ->values(); + } +} diff --git a/src/Commands/ModelExpiresDeleteCommand.php b/src/Commands/ModelExpiresDeleteCommand.php new file mode 100644 index 0000000..a9a657d --- /dev/null +++ b/src/Commands/ModelExpiresDeleteCommand.php @@ -0,0 +1,70 @@ +option('chunk'); + + $models = $this->models(); + + if ($models->isEmpty()) { + $this->info('No expired models found.'); + + return self::FAILURE; + } + + Event::listen(ExpiredModelsDeleted::class, function ($event) { + $this->line("{$event->model}: {$event->count} records"); + }); + + $models->each(function ($model) use ($chunkSize) { + $total = $this->when( + $this->option('mass'), + fn () => $model::massDeleteExpired($chunkSize), + fn () => $model::deleteExpired($chunkSize), + ); + + if ($total === 0) { + $this->line("{$model}: 0 records"); + } + }); + + Event::forget(ExpiredModelsDeleted::class); + + $this->comment('All done'); + + return self::SUCCESS; + } + + protected function models(): Collection + { + return Config::getExpirationModel() + ->newQuery() + ->groupBy('model_type') + ->pluck('model_type') + ->map(fn ($alias) => Relation::getMorphedModel($alias)) + ->filter(fn ($model) => class_exists($model)) + ->filter(fn ($model) => in_array(HasExpiration::class, class_uses_recursive($model))) + ->values(); + } +} diff --git a/src/Events/ExpiredModelsDeleted.php b/src/Events/ExpiredModelsDeleted.php new file mode 100644 index 0000000..667729e --- /dev/null +++ b/src/Events/ExpiredModelsDeleted.php @@ -0,0 +1,16 @@ + $model->setExpiresAt( + expiresAt: static::defaultExpiresAt(), + deletesAt: static::defaultDeletesAt(), + ) + ); + + static::deleted( + callback: fn ($model) => $model->expiration()->delete() + ); + } + + public function expiration(): MorphOne + { + return $this->morphOne(Config::getExpirationModel(), 'model'); + } + + public function setExpiresAt( + Carbon $expiresAt = null, + Carbon $deletesAt = null + ): self { + $this + ->expiration() + ->updateOrCreate([ + 'model_id' => $this->getKey(), + 'model_type' => $this->getMorphClass(), + ], [ + 'expires_at' => $expiresAt?->startOfDay(), + 'deletes_at' => $deletesAt?->startOfDay(), + ]); + + return $this; + } + + protected static function defaultExpiresAt(): ?Carbon + { + return Config::defaultExpiresAt(); + } + + protected static function defaultDeletesAt(): ?Carbon + { + return Config::defaultDeletesAt(); + } + + public function getExpiresAt(): ?Carbon + { + return $this + ->expiration() + ->value('expires_at'); + } + + public function getDeletesAt(): ?Carbon + { + return $this + ->expiration() + ->value('deletes_at'); + } + + public function isExpired(): bool + { + return (bool) $this->getExpiresAt()?->isPast(); + } + + public function getDaysLeftToExpiration(): ?int + { + $expiresAt = $this->getExpiresAt(); + + if (is_null($expiresAt)) { + return null; + } + + if ($expiresAt->isPast()) { + return 0; + } + + return $expiresAt + ->startOfDay() + ->diffInDays( + now()->startOfDay() + ); + } + + public function getDaysLeftToDeletion(): ?int + { + $deletesAt = $this->getDeletesAt(); + + if (is_null($deletesAt)) { + return null; + } + + if ($deletesAt->isPast()) { + return 0; + } + + return $deletesAt + ->startOfDay() + ->diffInDays( + now()->startOfDay() + ); + } + + public function canExpire(): bool + { + return ! is_null( + $this->getExpiresAt() + ); + } + + public function sendModelExpiringNotification(): void + { + $notification = $this->getModelExpiringNotification(); + + if (is_null($notification)) { + return; + } + + Notification::route( + channel: 'mail', + route: $this->getModelExpiringNotifiables() + )->notify( + notification: new $notification($this) + ); + } + + public function getModelExpiringNotifiables(): array + { + return Config::getModelExpiringNotifiables(); + } + + public function getModelExpiringNotification(): ?string + { + return Config::getModelExpiringNotification(); + } + + public static function fireExpiringEventBeforeDays(): array + { + return []; + } + + public static function deleteExpired(int $chunkSize = 1000): int + { + $total = 0; + + static::onlyExpired() + ->whereRelation('expiration', 'deletes_at', '<=', now()->startOfDay()) + ->chunkById($chunkSize, function (Collection $models) use (&$total) { + $models->each->delete(); + + $total += $models->count(); + + ExpiredModelsDeleted::dispatch(static::class, $total); + }); + + return $total; + } + + public static function massDeleteExpired(int $chunkSize = 1000): int + { + $query = tap( + value: static::onlyExpired() + ->whereRelation('expiration', 'deletes_at', '<=', now()->startOfDay()), + callback: fn (Builder $query) => $query->when( + ! $query->getQuery()->limit, + fn (Builder $query) => $query->limit($chunkSize) + ) + ); + + $total = 0; + + do { + $total += $count = $query->delete(); + + if ($count > 0) { + ExpiredModelsDeleted::dispatch(static::class, $total); + } + } while ($count > 0); + + return $total; + } +} diff --git a/src/Listeners/SendModelExpiringNotification.php b/src/Listeners/SendModelExpiringNotification.php new file mode 100644 index 0000000..949f7a4 --- /dev/null +++ b/src/Listeners/SendModelExpiringNotification.php @@ -0,0 +1,19 @@ +model->sendModelExpiringNotification(); + } +} diff --git a/src/ModelExpiresServiceProvider.php b/src/ModelExpiresServiceProvider.php new file mode 100644 index 0000000..00b4d2f --- /dev/null +++ b/src/ModelExpiresServiceProvider.php @@ -0,0 +1,42 @@ +name('laravel-model-expires') + ->hasConfigFile() + ->hasMigration('create_expirations_table') + ->hasCommands([ + ModelExpiresCheckCommand::class, + ModelExpiresDeleteCommand::class, + ]) + ->hasInstallCommand( + fn (InstallCommand $command) => $command + ->publishConfigFile() + ->publishMigrations() + ->askToRunMigrations() + ->askToStarRepoOnGitHub('maize-tech/laravel-model-expires') + ); + } + + public function packageBooted(): void + { + Event::listen( + events: ModelExpiring::class, + listener: [SendModelExpiringNotification::class, 'handle'] + ); + } +} diff --git a/src/Models/Expiration.php b/src/Models/Expiration.php new file mode 100644 index 0000000..02e94ce --- /dev/null +++ b/src/Models/Expiration.php @@ -0,0 +1,25 @@ + */ + protected $fillable = [ + 'model_id', + 'model_type', + 'expires_at', + 'deletes_at', + ]; + + /** @var array */ + protected $casts = [ + 'expires_at' => 'datetime', + 'deletes_at' => 'datetime', + ]; +} diff --git a/src/Notifications/ModelExpiringNotification.php b/src/Notifications/ModelExpiringNotification.php new file mode 100644 index 0000000..c3d84c0 --- /dev/null +++ b/src/Notifications/ModelExpiringNotification.php @@ -0,0 +1,33 @@ +greeting(__('Expiration notification')) + ->line(__('The :Model :key is expiring in :amount days', [ + 'model' => $this->model->getMorphClass(), + 'key' => $this->model->getKey(), + /** @phpstan-ignore-next-line */ + 'amount' => $this->model->getDaysLeftToExpiration(), + ])); + } +} diff --git a/src/Scopes/ExpirationScope.php b/src/Scopes/ExpirationScope.php new file mode 100644 index 0000000..ce0664f --- /dev/null +++ b/src/Scopes/ExpirationScope.php @@ -0,0 +1,47 @@ +extensions as $extension) { + $this->{"add{$extension}"}($builder); + } + } + + protected function addWithoutExpired(Builder $builder): void + { + $builder->macro('withoutExpired', function (Builder $builder) { + return $builder + ->withoutGlobalScope($this) + ->whereRelation('expiration', 'expires_at', '=', null) + ->orWhereRelation('expiration', 'expires_at', '>', now()->startOfDay()); + }); + } + + protected function addOnlyExpired(Builder $builder): void + { + $builder->macro('onlyExpired', function (Builder $builder) { + return $builder + ->withoutGlobalScope($this) + ->whereRelation('expiration', 'expires_at', '<=', now()->startOfDay()); + }); + } +} diff --git a/src/Support/Config.php b/src/Support/Config.php new file mode 100644 index 0000000..120e130 --- /dev/null +++ b/src/Support/Config.php @@ -0,0 +1,71 @@ +startOfDay() + ->addDays($days); + } + + public static function defaultDeletesAt(int $days = null): ?Carbon + { + $days ??= config('model-expires.model.deletes_after_days'); + + if (is_null($days)) { + return null; + } + + if ($days < 1) { + throw new Exception(); + } + + return static::defaultExpiresAt() + ?->startOfDay() + ?->addDays($days); + } + + public static function getExpiringNotificationEnabled(): bool + { + return config('model-expires.expiring_notification.enabled') + ?? false; + } + + public static function getModelExpiringNotification(): ?string + { + return config('model-expires.expiring_notification.notification'); + } + + public static function getModelExpiringNotifiables(): array + { + return Arr::wrap( + config('model-expires.expiring_notification.notifiables') + ); + } +} diff --git a/tests/HasExpirationTest.php b/tests/HasExpirationTest.php new file mode 100644 index 0000000..a24dc0b --- /dev/null +++ b/tests/HasExpirationTest.php @@ -0,0 +1,181 @@ +freeze(); + + $model::factory()->count(5)->create(); + + expect($model::count())->toBe(5); + + $expirations = Expiration::get(); + + expect($expirations)->toHaveCount(5); + + $expirations->each( + fn ($expiration) => expect($expiration->expires_at?->timestamp)->toBe($expiresAt) + ); + + $expirations->each( + fn ($expiration) => expect($expiration->deletes_at?->timestamp)->toBe($deletesAt) + ); +})->with([ + [ + 'model' => User::class, + 'expiresAt' => null, + 'deletesAt' => null, + ], + [ + 'model' => Tenant::class, + 'expiresAt' => fn () => now()->startOfDay()->addDays(365)->timestamp, + 'deletesAt' => null, + ], +]); + +it('can set expires_at', function ($model, $index, $days, $default) { + $users = $model::factory()->count(3)->create(); + + $expirations = Expiration::get(); + + expect($users[0]->getExpiresAt()?->timestamp)->toBe($default?->timestamp); + expect($users[1]->getExpiresAt()?->timestamp)->toBe($default?->timestamp); + expect($users[2]->getExpiresAt()?->timestamp)->toBe($default?->timestamp); + + $date = now()->addDays($days); + + $users[$index]->setExpiresAt($date); + + $expirations = Expiration::get(); + expect($expirations[$index]->expires_at)->toBeInstanceOf(Carbon::class); + expect($expirations[$index]->expires_at->timestamp)->toBe($date->timestamp); + + foreach (Arr::except([0, 1, 2], $index) as $i) { + expect($expirations[$i]->expires_at?->timestamp)->toBe($default?->timestamp); + } +})->with([ + ['model' => User::class, 'index' => 0, 'days' => 2, 'default' => null], + ['model' => User::class, 'index' => 1, 'days' => 4, 'default' => null], + ['model' => User::class, 'index' => 2, 'days' => 20, 'default' => null], + ['model' => User::class, 'index' => 2, 'days' => -10, 'default' => null], + ['model' => Tenant::class, 'index' => 0, 'days' => 2, 'default' => fn () => now()->startOfDay()->addDays(365)], + ['model' => Tenant::class, 'index' => 1, 'days' => 4, 'default' => fn () => now()->startOfDay()->addDays(365)], + ['model' => Tenant::class, 'index' => 2, 'days' => 20, 'default' => fn () => now()->startOfDay()->addDays(365)], + ['model' => Tenant::class, 'index' => 2, 'days' => -10, 'default' => fn () => now()->startOfDay()->addDays(365)], +]); + +it('can get expires_at', function ($model, $index, $days, $default) { + testTime()->freeze(); + + $users = $model::factory()->count(3)->create(); + + expect($users[0]->getExpiresAt()?->timestamp)->toBe($default?->timestamp); + expect($users[1]->getExpiresAt()?->timestamp)->toBe($default?->timestamp); + expect($users[2]->getExpiresAt()?->timestamp)->toBe($default?->timestamp); + + $date = now()->addDays($days); + + $users[$index]->setExpiresAt($date); + + expect($users[$index]->getExpiresAt())->toBeInstanceOf(Carbon::class); + expect($users[$index]->getExpiresAt()->timestamp)->toBe($date->timestamp); + + foreach (Arr::except([0, 1, 2], $index) as $i) { + expect($users[$i]->getExpiresAt()?->timestamp)->toBe($default?->timestamp); + } +})->with([ + ['model' => User::class, 'index' => 0, 'days' => 2, 'default' => null], + ['model' => User::class, 'index' => 1, 'days' => 4, 'default' => null], + ['model' => User::class, 'index' => 2, 'days' => 20, 'default' => null], + ['model' => User::class, 'index' => 2, 'days' => -10, 'default' => null], + ['model' => Tenant::class, 'index' => 0, 'days' => 2, 'default' => fn () => now()->startOfDay()->addDays(365)], + ['model' => Tenant::class, 'index' => 1, 'days' => 4, 'default' => fn () => now()->startOfDay()->addDays(365)], + ['model' => Tenant::class, 'index' => 2, 'days' => 20, 'default' => fn () => now()->startOfDay()->addDays(365)], + ['model' => Tenant::class, 'index' => 2, 'days' => -10, 'default' => fn () => now()->startOfDay()->addDays(365)], +]); + +it('can get isExpired', function ($model, $index, $months) { + testTime()->freeze(); + + $users = $model::factory()->count(3)->create(); + + expect($users[0]->isExpired())->toBeFalse(); + expect($users[1]->isExpired())->toBeFalse(); + expect($users[2]->isExpired())->toBeFalse(); + + $date = now()->addMonths($months); + + $users[$index]->setExpiresAt($date); + + testTime()->addMonths(11); + + expect($users[$index]->isExpired())->toBeTrue(); + foreach (Arr::except([0, 1, 2], $index) as $i) { + expect($users[$i]->isExpired())->toBeFalse(); + } +})->with([ + ['model' => User::class, 'index' => 0, 'months' => -1], + ['model' => User::class, 'index' => 1, 'months' => 5], + ['model' => User::class, 'index' => 2, 'months' => 10], + ['model' => Tenant::class, 'index' => 0, 'months' => -1], + ['model' => Tenant::class, 'index' => 1, 'months' => 5], + ['model' => Tenant::class, 'index' => 2, 'months' => 10], +]); + +it('can get getDaysLeftToExpiration', function ($model, $index, $days, $default, $remainingIndex, $remaining) { + testTime()->freeze(); + + $users = $model::factory()->count(3)->create(); + + expect($users[0]->getDaysLeftToExpiration())->toBe($default); + expect($users[1]->getDaysLeftToExpiration())->toBe($default); + expect($users[2]->getDaysLeftToExpiration())->toBe($default); + + $date = now()->addDays($days); + $users[$index]->setExpiresAt($date); + + testTime()->addDays(4); + + expect($users[$index]->getDaysLeftToExpiration())->toBe($remainingIndex); + foreach (Arr::except([0, 1, 2], $index) as $i) { + expect($users[$i]->getDaysLeftToExpiration())->toBe($remaining); + } +})->with([ + ['model' => User::class, 'index' => 0, 'days' => -1, 'default' => null, 'remainingIndex' => 0, 'remaining' => null], + ['model' => User::class, 'index' => 1, 'days' => 5, 'default' => null, 'remainingIndex' => 1, 'remaining' => null], + ['model' => User::class, 'index' => 2, 'days' => 10, 'default' => null, 'remainingIndex' => 6, 'remaining' => null], + ['model' => Tenant::class, 'index' => 0, 'days' => -1, 'default' => 365, 'remaining' => 0, 'remainingIndex' => 361], + ['model' => Tenant::class, 'index' => 1, 'days' => 5, 'default' => 365, 'remaining' => 1, 'remainingIndex' => fn () => 361], + ['model' => Tenant::class, 'index' => 2, 'days' => 10, 'default' => 365, 'remaining' => 6, 'remainingIndex' => fn () => 361], +]); + +it('can get canExpire', function ($model, $index, $days, $default) { + testTime()->freeze(); + + $users = $model::factory()->count(3)->create(); + + expect($users[0]->canExpire())->toBe($default); + expect($users[1]->canExpire())->toBe($default); + expect($users[2]->canExpire())->toBe($default); + + $date = now()->addDays($days); + $users[$index]->setExpiresAt($date); + + expect($users[$index]->canExpire())->toBe(true); + foreach (Arr::except([0, 1, 2], $index) as $i) { + expect($users[$i]->canExpire())->toBe($default); + } +})->with([ + ['model' => User::class, 'index' => 0, 'days' => -1, 'default' => false], + ['model' => User::class, 'index' => 1, 'days' => 5, 'default' => false], + ['model' => User::class, 'index' => 2, 'days' => 10, 'default' => false], + ['model' => Tenant::class, 'index' => 0, 'days' => -1, 'default' => true], + ['model' => Tenant::class, 'index' => 1, 'days' => 5, 'default' => true], + ['model' => Tenant::class, 'index' => 2, 'days' => 10, 'default' => true], +]); diff --git a/tests/ModelExpiresCheckCommandTest.php b/tests/ModelExpiresCheckCommandTest.php new file mode 100644 index 0000000..33c0947 --- /dev/null +++ b/tests/ModelExpiresCheckCommandTest.php @@ -0,0 +1,128 @@ +freeze(); + config()->set('model-expires.model.expires_after_days', 5); + + $users = User::factory()->count(10)->create(); + + $users[0]->setExpiresAt(); + $users[1]->setExpiresAt(); + + Event::fake(); + + artisan(ModelExpiresCheckCommand::class); + + Event::assertDispatchedTimes( + ModelExpiring::class, + 8 + ); +}); + +it('should fire ExpiringModel event multiple times', function () { + testTime()->freeze(); + config()->set('model-expires.model.expires_after_days', 10); + + $users = User::factory()->count(10)->create(); + + $users[0]->setExpiresAt(); + $users[1]->setExpiresAt(); + + Event::fake(); + + artisan(ModelExpiresCheckCommand::class); + + Event::assertDispatchedTimes( + ModelExpiring::class, + 8 + ); + + testTime()->addDays(5); + + Event::fake(); + + artisan(ModelExpiresCheckCommand::class); + + Event::assertDispatchedTimes( + ModelExpiring::class, + 8 + ); +}); + +it('should not fire ExpiringModel event if expires is different', function () { + testTime()->freeze(); + config()->set('model-expires.model.expires_after_days', 5); + + User::factory()->count(10)->create(); + + testTime()->addDays(10); + + Event::fake(); + + artisan(ModelExpiresCheckCommand::class); + + Event::assertDispatchedTimes( + ModelExpiring::class, + 0 + ); +}); + +it('should activate SendModelExpiringNotification listener', function () { + testTime()->freeze(); + config()->set('model-expires.model.expires_after_days', 5); + + User::factory()->count(10)->create(); + + Event::fake(); + + artisan(ModelExpiresCheckCommand::class); + + Event::assertListening( + ModelExpiring::class, + [SendModelExpiringNotification::class, 'handle'] + ); +}); + +it('should not send ModelExpiringNotification notification when disabled', function () { + testTime()->freeze(); + config()->set('model-expires.model.expires_after_days', 5); + config()->set('model-expires.expiring_notification.enabled', false); + + User::factory()->count(10)->create(); + + Notification::fake(); + + artisan(ModelExpiresCheckCommand::class); + + Notification::assertNothingSent(); +}); + +it('should send ModelExpiringNotification notification', function () { + testTime()->freeze(); + config()->set('model-expires.model.expires_after_days', 5); + config()->set('model-expires.expiring_notification.notifiables', [ + 'test@example.com', + ]); + + User::factory()->count(10)->create(); + + Notification::fake(); + + artisan(ModelExpiresCheckCommand::class); + + Notification::assertSentTimes( + ModelExpiringNotification::class, + 10 + ); +}); diff --git a/tests/ModelExpiresDeleteCommandTest.php b/tests/ModelExpiresDeleteCommandTest.php new file mode 100644 index 0000000..8fe727d --- /dev/null +++ b/tests/ModelExpiresDeleteCommandTest.php @@ -0,0 +1,101 @@ +freeze(); + config()->set('model-expires.model.expires_after_days', 5); + config()->set('model-expires.model.deletes_after_days', 10); + + User::factory()->count(10)->create(); + + testTime()->addDays(365); + + Event::fake(); + + artisan(ModelExpiresDeleteCommand::class); + + Event::assertDispatched( + ExpiredModelsDeleted::class, + fn (ExpiredModelsDeleted $event) => $event->model === User::class && $event->count === 10 + ); +}); + +it('should delete all deletable models', function () { + testTime()->freeze(); + config()->set('model-expires.model.expires_after_days', 5); + config()->set('model-expires.model.deletes_after_days', 10); + + $users = User::factory()->count(10)->create(); + + $users[0]->setExpiresAt(); + $users[1]->setExpiresAt(); + + testTime()->addDays(365); + + Event::fake(); + + artisan(ModelExpiresDeleteCommand::class); + + Event::assertDispatched(UserDeletedEvent::class); + + Event::assertDispatched( + ExpiredModelsDeleted::class, + fn (ExpiredModelsDeleted $event) => $event->model === User::class && $event->count === 8 + ); + + expect(User::count())->toBe(2); +}); + +it('should mass delete expired models when option is enabled', function () { + testTime()->freeze(); + config()->set('model-expires.model.expires_after_days', 5); + config()->set('model-expires.model.deletes_after_days', 10); + + User::factory()->count(10)->create(); + + testTime()->addDays(365); + + Event::fake(); + + artisan(ModelExpiresDeleteCommand::class, [ + '--mass' => true, + ]); + + Event::assertNotDispatched(UserDeletedEvent::class); + + Event::assertDispatched( + ExpiredModelsDeleted::class, + fn (ExpiredModelsDeleted $event) => $event->model === User::class && $event->count === 10 + ); + + expect(User::count())->toBe(0); +}); + +it('should chunk model deletion', function () { + testTime()->freeze(); + config()->set('model-expires.model.expires_after_days', 5); + config()->set('model-expires.model.deletes_after_days', 10); + + User::factory()->count(10)->create(); + + testTime()->addDays(365); + + Event::fake(); + + artisan(ModelExpiresDeleteCommand::class, [ + '--chunk' => 5, + ]); + + Event::assertDispatchedTimes( + ExpiredModelsDeleted::class, + 2 + ); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..72f713b --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,5 @@ +in(__DIR__); diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php new file mode 100644 index 0000000..9b4f055 --- /dev/null +++ b/tests/ScopeTest.php @@ -0,0 +1,44 @@ +freeze(); + + $models = $model::factory()->count(5)->create(); + + expect($model::count(5))->toBe(5); + + expect($model::query()->onlyExpired()->count())->toBe(0); + + $models[2]->setExpiresAt( + now()->addDays(-3) + ); + + expect($model::query()->onlyExpired()->count())->toBe(1); +})->with([ + ['model' => User::class], + ['model' => Tenant::class], +]); + +it('can exclude expired model', function ($model) { + testTime()->freeze(); + + $models = $model::factory()->count(5)->create(); + + expect($model::count(5))->toBe(5); + + expect($model::query()->withoutExpired()->count())->toBe(5); + + $models[2]->setExpiresAt( + now()->addDays(-3) + ); + + expect($model::query()->withoutExpired()->count())->toBe(4); +})->with([ + ['model' => User::class], + ['model' => Tenant::class], +]); diff --git a/tests/Support/Events/UserDeletedEvent.php b/tests/Support/Events/UserDeletedEvent.php new file mode 100644 index 0000000..db92d18 --- /dev/null +++ b/tests/Support/Events/UserDeletedEvent.php @@ -0,0 +1,14 @@ + UserDeletedEvent::class, + ]; + + protected static function newFactory(): Factory + { + return UserFactory::new(); + } + + public static function fireExpiringEventBeforeDays(): array + { + return [ + 5, + 10, + ]; + } +} diff --git a/tests/Support/TestCase.php b/tests/Support/TestCase.php new file mode 100644 index 0000000..1c4eeb1 --- /dev/null +++ b/tests/Support/TestCase.php @@ -0,0 +1,44 @@ +set('database.default', 'testing'); + + $migration = include __DIR__.'/../../database/migrations/create_expirations_table.php.stub'; + $migration->up(); + + Schema::create('tenants', function (Blueprint $table) { + $table->id(); + $table->timestamps(); + }); + + Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->timestamps(); + }); + + Relation::morphMap([ + 'user' => User::class, + 'tenant' => Tenant::class, + ]); + } +}