diff --git a/app/Events/CheckoutablesCheckedOutInBulk.php b/app/Events/CheckoutablesCheckedOutInBulk.php new file mode 100644 index 000000000000..4b75b32145b5 --- /dev/null +++ b/app/Events/CheckoutablesCheckedOutInBulk.php @@ -0,0 +1,24 @@ +authorize('checkout', Asset::class); try { @@ -717,6 +721,15 @@ public function storeCheckout(AssetCheckoutRequest $request) : RedirectResponse }); 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 908dd58dfded..6a56ae2f9e34 100644 --- a/app/Listeners/CheckoutableListener.php +++ b/app/Listeners/CheckoutableListener.php @@ -33,6 +33,7 @@ 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; @@ -428,12 +429,17 @@ private function newMicrosoftTeamsWebhookEnabled(): bool 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; } @@ -451,6 +457,10 @@ private function shouldSendCheckoutEmailToUser(Model $checkoutable): bool 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 000000000000..c1ff57d91132 --- /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 000000000000..9a4ccee85c9c --- /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 324c1c8f29b3..8f7c44c9f738 100644 --- a/app/Mail/CheckoutAssetMail.php +++ b/app/Mail/CheckoutAssetMail.php @@ -14,7 +14,6 @@ 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 Mailable diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 1f08b445c9ec..9143cdc8f5a0 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 733c52668ed4..eb0d8b828bbd 100644 --- a/database/factories/CategoryFactory.php +++ b/database/factories/CategoryFactory.php @@ -214,4 +214,34 @@ public function doesNotRequireAcceptance() '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 707390e4f0ab..021e20911986 100644 --- a/resources/lang/en-US/mail.php +++ b/resources/lang/en-US/mail.php @@ -79,12 +79,14 @@ '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 000000000000..dfd33815b924 --- /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 6f6f250b9003..99ea17bb4aca 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 @@ public function testCanBulkCheckoutAssets() $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 000000000000..4a1658e9fb12 --- /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 000000000000..ebd84acca066 --- /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); + } +}