diff --git a/app/Events/CheckoutablesCheckedOutInBulk.php b/app/Events/CheckoutablesCheckedOutInBulk.php new file mode 100644 index 0000000000..4b75b32145 --- /dev/null +++ b/app/Events/CheckoutablesCheckedOutInBulk.php @@ -0,0 +1,24 @@ +authorize('checkout', Asset::class); try { @@ -730,6 +734,15 @@ class BulkAssetsController extends Controller }); if (! $errors) { + CheckoutablesCheckedOutInBulk::dispatch( + $assets, + $target, + $admin, + $checkout_at, + $expected_checkin, + e($request->get('note')), + ); + // Redirect to the new asset page return redirect()->to('hardware')->with('success', trans_choice('admin/hardware/message.multi-checkout.success', $asset_ids)); } diff --git a/app/Listeners/CheckoutableListener.php b/app/Listeners/CheckoutableListener.php index db0c54724d..54177a7448 100644 --- a/app/Listeners/CheckoutableListener.php +++ b/app/Listeners/CheckoutableListener.php @@ -33,6 +33,7 @@ use App\Notifications\CheckoutConsumableNotification; use App\Notifications\CheckoutLicenseSeatNotification; use GuzzleHttp\Exception\ClientException; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Context; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Notification; use Exception; @@ -441,12 +442,17 @@ class CheckoutableListener private function shouldSendCheckoutEmailToUser(Model $checkoutable): bool { /** - * Send an email if any of the following conditions are met: + * Send an email if we didn't get here from a bulk checkout + * and any of the following conditions are met: * 1. The asset requires acceptance * 2. The item has a EULA * 3. The item should send an email at check-in/check-out */ + if (Context::get('action') === 'bulk_asset_checkout') { + return false; + } + if ($checkoutable->requireAcceptance()) { return true; } @@ -464,6 +470,10 @@ class CheckoutableListener private function shouldSendEmailToAlertAddress($acceptance = null): bool { + if (Context::get('action') === 'bulk_asset_checkout') { + return false; + } + $setting = Setting::getSettings(); if (!$setting) { diff --git a/app/Listeners/CheckoutablesCheckedOutInBulkListener.php b/app/Listeners/CheckoutablesCheckedOutInBulkListener.php new file mode 100644 index 0000000000..c1ff57d911 --- /dev/null +++ b/app/Listeners/CheckoutablesCheckedOutInBulkListener.php @@ -0,0 +1,154 @@ +listen( + CheckoutablesCheckedOutInBulk::class, + CheckoutablesCheckedOutInBulkListener::class + ); + } + + public function handle(CheckoutablesCheckedOutInBulk $event): void + { + $notifiableUser = $this->getNotifiableUser($event); + + $shouldSendEmailToUser = $this->shouldSendCheckoutEmailToUser($notifiableUser, $event->assets); + $shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($event->assets); + + if ($shouldSendEmailToUser && $notifiableUser) { + try { + Mail::to($notifiableUser)->send(new BulkAssetCheckoutMail( + $event->assets, + $event->target, + $event->admin, + $event->checkout_at, + $event->expected_checkin, + $event->note, + )); + + Log::info('BulkAssetCheckoutMail sent to checkout target'); + } catch (Exception $e) { + Log::debug("Exception caught during BulkAssetCheckoutMail to target: " . $e->getMessage()); + } + } + + if ($shouldSendEmailToAlertAddress && Setting::getSettings()->admin_cc_email) { + try { + Mail::to(Setting::getSettings()->admin_cc_email)->send(new BulkAssetCheckoutMail( + $event->assets, + $event->target, + $event->admin, + $event->checkout_at, + $event->expected_checkin, + $event->note, + )); + + Log::info('BulkAssetCheckoutMail sent to admin_cc_email'); + } catch (Exception $e) { + Log::debug("Exception caught during BulkAssetCheckoutMail to admin_cc_email: " . $e->getMessage()); + } + } + } + + private function shouldSendCheckoutEmailToUser(?User $user, Collection $assets): bool + { + if (!$user?->email) { + return false; + } + + if ($this->hasAssetWithEula($assets)) { + return true; + } + + if ($this->hasAssetWithCategorySettingToSendEmail($assets)) { + return true; + } + + return $this->hasAssetThatRequiresAcceptance($assets); + } + + private function shouldSendEmailToAlertAddress(Collection $assets): bool + { + $setting = Setting::getSettings(); + + if (!$setting) { + return false; + } + + if ($setting->admin_cc_always) { + return true; + } + + if (!$this->hasAssetThatRequiresAcceptance($assets)) { + return false; + } + + return (bool) $setting->admin_cc_email; + } + + private function hasAssetWithEula(Collection $assets): bool + { + foreach ($assets as $asset) { + if ($asset->getEula()) { + return true; + } + } + + return false; + } + + private function hasAssetWithCategorySettingToSendEmail(Collection $assets): bool + { + foreach ($assets as $asset) { + if ($asset->checkin_email()) { + return true; + } + } + + return false; + } + + private function hasAssetThatRequiresAcceptance(Collection $assets): bool + { + foreach ($assets as $asset) { + if ($asset->requireAcceptance()) { + return true; + } + } + + return false; + } + + private function getNotifiableUser(CheckoutablesCheckedOutInBulk $event): ?Model + { + $target = $event->target; + + if ($target instanceof Asset) { + $target->load('assignedTo'); + return $target->assignedto; + } + + if ($target instanceof Location) { + return $target->manager; + } + + return $target; + } +} diff --git a/app/Mail/BulkAssetCheckoutMail.php b/app/Mail/BulkAssetCheckoutMail.php new file mode 100644 index 0000000000..9a4ccee85c --- /dev/null +++ b/app/Mail/BulkAssetCheckoutMail.php @@ -0,0 +1,165 @@ +requires_acceptance = $this->requiresAcceptance(); + + $this->loadCustomFieldsOnAssets(); + $this->loadEulasOnAssets(); + + $this->assetsByCategory = $this->groupAssetsByCategory(); + } + + public function envelope(): Envelope + { + return new Envelope( + subject: $this->getSubject(), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'mail.markdown.bulk-asset-checkout-mail', + with: [ + 'introduction' => $this->getIntroduction(), + 'requires_acceptance' => $this->requires_acceptance, + 'requires_acceptance_info' => $this->getRequiresAcceptanceInfo(), + 'requires_acceptance_prompt' => $this->getRequiresAcceptancePrompt(), + 'singular_eula' => $this->getSingularEula(), + ], + ); + } + + public function attachments(): array + { + return []; + } + + private function getSubject(): string + { + if ($this->assets->count() > 1) { + return ucfirst(trans('general.assets_checked_out_count')); + } + + return trans('mail.Asset_Checkout_Notification', ['tag' => $this->assets->first()->asset_tag]); + } + + private function loadCustomFieldsOnAssets(): void + { + $this->assets = $this->assets->map(function (Asset $asset) { + $fields = $asset->model?->fieldset?->fields->filter(function (CustomField $field) { + return $field->show_in_email && !$field->field_encrypted; + }); + + $asset->setRelation('fields', $fields); + + return $asset; + }); + } + + private function loadEulasOnAssets(): void + { + $this->assets = $this->assets->map(function (Asset $asset) { + $asset->eula = $asset->getEula(); + + return $asset; + }); + } + + private function groupAssetsByCategory(): Collection + { + return $this->assets->groupBy(fn($asset) => $asset->model->category->id); + } + + private function getIntroduction(): string + { + if ($this->target instanceof Location) { + return trans_choice('mail.new_item_checked_location', $this->assets->count(), ['location' => $this->target->name]); + } + + return trans_choice('mail.new_item_checked', $this->assets->count()); + } + + private function getRequiresAcceptanceInfo(): ?string + { + if (!$this->requires_acceptance) { + return null; + } + + return trans_choice('mail.items_checked_out_require_acceptance', $this->assets->count()); + } + + private function getRequiresAcceptancePrompt(): ?string + { + if (!$this->requires_acceptance) { + return null; + } + + $acceptanceUrl = $this->assets->count() === 1 + ? route('account.accept.item', $this->assets->first()) + : route('account.accept'); + + return + sprintf( + '**[✔ %s](%s)**', + trans_choice('mail.click_here_to_review_terms_and_accept_item', $this->assets->count()), + $acceptanceUrl, + ); + } + + private function getSingularEula() + { + // get unique categories from all assets + $categories = $this->assets->pluck('model.category.id')->unique(); + + // if assets do not have the same category then return early... + if ($categories->count() > 1) { + return null; + } + + // if assets do have the same category then return the shared EULA + if ($categories->count() === 1) { + return $this->assets->first()->getEula(); + } + } + + private function requiresAcceptance(): bool + { + foreach ($this->assets as $asset) { + if ($asset->requireAcceptance()) { + return true; + } + } + + return false; + } +} diff --git a/app/Mail/CheckoutAssetMail.php b/app/Mail/CheckoutAssetMail.php index ba79e59ef9..47410f0585 100644 --- a/app/Mail/CheckoutAssetMail.php +++ b/app/Mail/CheckoutAssetMail.php @@ -12,7 +12,6 @@ use Illuminate\Mail\Mailables\Address; use Illuminate\Mail\Mailables\Attachment; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; -use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Queue\SerializesModels; class CheckoutAssetMail extends BaseMailable diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 1f08b445c9..9143cdc8f5 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Listeners\CheckoutableListener; +use App\Listeners\CheckoutablesCheckedOutInBulkListener; use App\Listeners\LogListener; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; @@ -31,5 +32,6 @@ class EventServiceProvider extends ServiceProvider protected $subscribe = [ LogListener::class, CheckoutableListener::class, + CheckoutablesCheckedOutInBulkListener::class, ]; } diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php index 95d282da01..d2dd6a8b0b 100644 --- a/database/factories/CategoryFactory.php +++ b/database/factories/CategoryFactory.php @@ -215,4 +215,34 @@ class CategoryFactory extends Factory 'require_acceptance' => false, ]); } + + public function sendsCheckinEmail() + { + return $this->state([ + 'checkin_email' => true, + ]); + } + + public function doesNotSendCheckinEmail() + { + return $this->state([ + 'checkin_email' => false, + ]); + } + + public function hasLocalEula() + { + return $this->state([ + 'use_default_eula' => false, + 'eula_text' => 'Some EULA text here', + ]); + } + + public function withNoLocalOrGlobalEula() + { + return $this->state([ + 'use_default_eula' => false, + 'eula_text' => '', + ]); + } } diff --git a/resources/lang/en-US/mail.php b/resources/lang/en-US/mail.php index dbdceca35a..7bcec8349f 100644 --- a/resources/lang/en-US/mail.php +++ b/resources/lang/en-US/mail.php @@ -84,12 +84,14 @@ return [ 'new_item_checked' => 'A new item has been checked out under your name, details are below.|:count new items have been checked out under your name, details are below.', 'new_item_checked_with_acceptance' => 'A new item has been checked out under your name that requires acceptance, details are below.|:count new items have been checked out under your name that requires acceptance, details are below.', 'new_item_checked_location' => 'A new item has been checked out to :location, details are below.|:count new items have been checked out to :location, details are below.', + 'items_checked_out_require_acceptance' => 'The checked out item requires acceptance.|One or more items require acceptance.', 'recent_item_checked' => 'An item was recently checked out under your name that requires acceptance, details are below.', 'notes' => 'Notes', 'password' => 'Password', 'password_reset' => 'Password Reset', 'read_the_terms' => 'Please read the terms of use below.', 'read_the_terms_and_click' => 'Please read the terms of use below, and click on the link at the bottom to confirm that you read and agree to the terms of use, and have received the asset.', + 'click_here_to_review_terms_and_accept_item' => 'Click here to review the terms of use and accept the item|Click here to review the terms of use and accept the items', 'requested' => 'Requested', 'reset_link' => 'Your Password Reset Link', 'reset_password' => 'Click here to reset your password:', diff --git a/resources/views/mail/markdown/bulk-asset-checkout-mail.blade.php b/resources/views/mail/markdown/bulk-asset-checkout-mail.blade.php new file mode 100644 index 0000000000..dfd33815b9 --- /dev/null +++ b/resources/views/mail/markdown/bulk-asset-checkout-mail.blade.php @@ -0,0 +1,92 @@ + + + + +{{ $introduction }} + +@if ($requires_acceptance) +{{ $requires_acceptance_info }} + +{{ $requires_acceptance_prompt }} +
+@endif + +@if ((isset($expected_checkin)) && ($expected_checkin!='')) +**{{ trans('mail.expecting_checkin_date') }}**: {{ Helper::getFormattedDateObject($expected_checkin, 'date', false) }} +@endif + +@if ($note) +**{{ trans('mail.additional_notes') }}**: {{ $note }} +@endif + +@foreach($assetsByCategory as $group) + + +**{{ $group->first()->model->category->name }}** + + +| | | +| ------------- | ------------- | +@foreach($group as $asset) +| **{{ trans('general.asset_tag') }}** | {{ $asset->display_name }}
{{trans('mail.serial').': '.$asset->serial}} | +@if (isset($asset->manufacturer)) +| **{{ trans('general.manufacturer') }}** | {{ $asset->manufacturer->name }} | +@endif +@if (isset($asset->model)) +| **{{ trans('general.asset_model') }}** | {{ $asset->model->name }} | +@endif +@if ((isset($asset->model?->model_number))) +| **{{ trans('general.model_no') }}** | {{ $asset->model->model_number }} | +@endif +@if (isset($asset->assetstatus)) +| **{{ trans('general.status') }}** | {{ $asset->assetstatus->name }} | +@endif +@if($asset->fields) +@foreach($asset->fields as $field) +@if ($asset->{ $field->db_column_name() } != '') +| **{{ $field->name }}** | {{ $asset->{ $field->db_column_name() } }} | +@endif +@endforeach +@endif +@if(!$loop->last) +|
|
| +@endif +@endforeach +
+ +@if (!$singular_eula && $group->first()->eula) +
+{{ $group->first()->eula }} +@endif + +
+@endforeach + +@if ($singular_eula) + +{{ $singular_eula }} + +@endif + +@if ($requires_acceptance) +{{ $requires_acceptance_prompt }} +@endif + +**{{ trans('general.administrator') }}**: {{ $admin->display_name }} + +{{ trans('mail.best_regards') }}
+ +{{ $snipeSettings->site_name }} +
diff --git a/tests/Feature/Checkouts/Ui/BulkAssetCheckoutTest.php b/tests/Feature/Checkouts/Ui/BulkAssetCheckoutTest.php index 6f6f250b90..99ea17bb4a 100644 --- a/tests/Feature/Checkouts/Ui/BulkAssetCheckoutTest.php +++ b/tests/Feature/Checkouts/Ui/BulkAssetCheckoutTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Checkouts\Ui; +use App\Mail\BulkAssetCheckoutMail; use App\Mail\CheckoutAssetMail; use App\Models\Asset; use App\Models\Company; @@ -59,10 +60,16 @@ class BulkAssetCheckoutTest extends TestCase $asset->last_checkout = $checkoutAt; $asset->expected_checkin = $expectedCheckin; $this->assertHasTheseActionLogs($asset, ['create', 'checkout']); //Note: '$this' gets auto-bound in closures, so this does work. + $this->assertDatabaseHas('checkout_acceptances', [ + 'checkoutable_type' => Asset::class, + 'checkoutable_id' => $asset->id, + 'assigned_to_id' => $user->id, + 'qty' => 1, + ]); }); - Mail::assertSent(CheckoutAssetMail::class, 2); - Mail::assertSent(CheckoutAssetMail::class, function (CheckoutAssetMail $mail) { + Mail::assertNotSent(CheckoutAssetMail::class); + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) { return $mail->hasTo('someone@example.com'); }); } diff --git a/tests/Feature/Notifications/Email/BulkCheckoutEmailTest.php b/tests/Feature/Notifications/Email/BulkCheckoutEmailTest.php new file mode 100644 index 0000000000..4a1658e9fb --- /dev/null +++ b/tests/Feature/Notifications/Email/BulkCheckoutEmailTest.php @@ -0,0 +1,252 @@ +settings->disableAdminCC(); + $this->settings->disableAdminCCAlways(); + + $this->assets = Asset::factory()->requiresAcceptance()->count(2)->create(); + $this->assignee = User::factory()->create(); + } + + public function test_sent_to_user() + { + $this->sendRequest(); + + $this->assertSingularCheckoutEmailNotSent(); + + Mail::assertSent(BulkAssetCheckoutMail::class, 1); + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) { + return $mail->hasTo($this->assignee->email); + }); + } + + public function test_sent_to_location_manager() + { + $manager = User::factory()->create(); + + $this->assignee = Location::factory()->for($manager, 'manager')->create(); + + $this->sendRequest(); + + $this->assertSingularCheckoutEmailNotSent(); + + Mail::assertSent(BulkAssetCheckoutMail::class, 1); + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) use ($manager) { + return $mail->hasTo($manager->email); + }); + } + + public function test_sent_to_user_asset_is_checked_out_to() + { + $user = User::factory()->create(); + + $this->assignee = Asset::factory()->assignedToUser($user)->create(); + + $this->sendRequest(); + + $this->assertSingularCheckoutEmailNotSent(); + + Mail::assertSent(BulkAssetCheckoutMail::class, 1); + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) use ($user) { + return $mail->hasTo($user->email); + }); + } + + public function test_not_sent_to_user_when_user_does_not_have_email_address() + { + $this->assignee = User::factory()->create(['email' => null]); + + $this->sendRequest(); + + $this->assertSingularCheckoutEmailNotSent(); + Mail::assertNotSent(BulkAssetCheckoutMail::class); + } + + public function test_not_sent_to_user_if_assets_do_not_require_acceptance() + { + $this->assets = Asset::factory()->doesNotRequireAcceptance()->count(2)->create(); + + $category = Category::factory() + ->doesNotRequireAcceptance() + ->doesNotSendCheckinEmail() + ->withNoLocalOrGlobalEula() + ->create(); + + $this->assets->each(fn($asset) => $asset->model->category()->associate($category)->save()); + + $this->sendRequest(); + + $this->assertSingularCheckoutEmailNotSent(); + Mail::assertNotSent(BulkAssetCheckoutMail::class); + } + + public function test_sent_when_assets_do_not_require_acceptance_but_have_a_eula() + { + $this->assets = Asset::factory()->count(2)->create(); + + $category = Category::factory() + ->doesNotRequireAcceptance() + ->doesNotSendCheckinEmail() + ->hasLocalEula() + ->create(); + + $this->assets->each(fn($asset) => $asset->model->category()->associate($category)->save()); + + $this->sendRequest(); + + $this->assertSingularCheckoutEmailNotSent(); + + Mail::assertSent(BulkAssetCheckoutMail::class, 1); + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) { + return $mail->hasTo($this->assignee->email); + }); + } + + public function test_sent_when_assets_do_not_require_acceptance_or_have_a_eula_but_category_is_set_to_send_email() + { + $this->assets = Asset::factory()->count(2)->create(); + + $category = Category::factory() + ->doesNotRequireAcceptance() + ->withNoLocalOrGlobalEula() + ->sendsCheckinEmail() + ->create(); + + $this->assets->each(fn($asset) => $asset->model->category()->associate($category)->save()); + + $this->sendRequest(); + + $this->assertSingularCheckoutEmailNotSent(); + + Mail::assertSent(BulkAssetCheckoutMail::class, 1); + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) { + return $mail->hasTo($this->assignee->email); + }); + } + + public function test_sent_to_cc_address_when_assets_require_acceptance() + { + $this->assets = Asset::factory()->requiresAcceptance()->count(2)->create(); + + $this->settings->enableAdminCC('cc@example.com')->disableAdminCCAlways(); + + $this->sendRequest(); + + $this->assertSingularCheckoutEmailNotSent(); + + Mail::assertSent(BulkAssetCheckoutMail::class, 2); + + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) { + return $mail->hasTo($this->assignee->email); + }); + + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) { + return $mail->hasTo('cc@example.com'); + }); + } + + public function test_sent_to_cc_address_when_assets_do_not_require_acceptance_or_have_eula_but_admin_cc_always_enabled() + { + $this->settings->enableAdminCC('cc@example.com')->enableAdminCCAlways(); + + $this->assets = Asset::factory()->doesNotRequireAcceptance()->count(2)->create(); + + $category = Category::factory() + ->doesNotRequireAcceptance() + ->doesNotSendCheckinEmail() + ->withNoLocalOrGlobalEula() + ->create(); + + $this->assets->each(fn($asset) => $asset->model->category()->associate($category)->save()); + + $this->sendRequest(); + + $this->assertSingularCheckoutEmailNotSent(); + + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) { + return $mail->hasTo('cc@example.com'); + }); + } + + public function test_not_sent_to_cc_address_if_assets_do_not_require_acceptance() + { + $this->settings->enableAdminCC('cc@example.com')->disableAdminCCAlways(); + + $this->assets = Asset::factory()->doesNotRequireAcceptance()->count(2)->create(); + + $category = Category::factory() + ->doesNotRequireAcceptance() + ->doesNotSendCheckinEmail() + ->withNoLocalOrGlobalEula() + ->create(); + + $this->assets->each(fn($asset) => $asset->model->category()->associate($category)->save()); + + $this->sendRequest(); + + $this->assertSingularCheckoutEmailNotSent(); + Mail::assertNotSent(BulkAssetCheckoutMail::class); + } + + private function sendRequest() + { + $assigned = match (get_class($this->assignee)) { + User::class => [ + 'checkout_to_type' => 'user', + 'assigned_user' => $this->assignee->id, + ], + Location::class => [ + 'checkout_to_type' => 'location', + 'assigned_location' => $this->assignee->id, + ], + Asset::class => [ + 'checkout_to_type' => 'asset', + 'assigned_asset' => $this->assignee->id, + ], + // we shouldn't get here... + default => [], + }; + + $this->actingAs(User::factory()->checkoutAssets()->viewAssets()->create()) + ->followingRedirects() + ->post(route('hardware.bulkcheckout.store'), [ + 'selected_assets' => $this->assets->pluck('id')->toArray(), + 'checkout_at' => now()->subWeek()->format('Y-m-d'), + 'expected_checkin' => now()->addWeek()->format('Y-m-d'), + 'note' => null, + ] + $assigned) + ->assertOk(); + } + + private function assertSingularCheckoutEmailNotSent(): static + { + Mail::assertNotSent(CheckoutAssetMail::class); + + return $this; + } +} diff --git a/tests/Feature/Notifications/Webhooks/WebhookNotificationsUponBulkAssetCheckoutTest.php b/tests/Feature/Notifications/Webhooks/WebhookNotificationsUponBulkAssetCheckoutTest.php new file mode 100644 index 0000000000..ebd84acca0 --- /dev/null +++ b/tests/Feature/Notifications/Webhooks/WebhookNotificationsUponBulkAssetCheckoutTest.php @@ -0,0 +1,39 @@ +settings->enableSlackWebhook(); + + $assets = Asset::factory()->count(2)->create(); + + $this->actingAs(User::factory()->checkoutAssets()->viewAssets()->create()) + ->followingRedirects() + ->post(route('hardware.bulkcheckout.store'), [ + 'selected_assets' => $assets->pluck('id')->toArray(), + 'checkout_to_type' => 'user', + 'assigned_user' => User::factory()->create()->id, + 'assigned_asset' => null, + 'checkout_at' => now()->subWeek()->format('Y-m-d'), + 'expected_checkin' => now()->addWeek()->format('Y-m-d'), + 'note' => null, + ]) + ->assertOk(); + + $this->assertSlackNotificationSent(CheckoutAssetNotification::class); + Notification::assertSentTimes(CheckoutAssetNotification::class, 2); + } +}