diff --git a/app/Http/Controllers/Api/AssetsController.php b/app/Http/Controllers/Api/AssetsController.php index 5f901ef49..79ab6f9fd 100644 --- a/app/Http/Controllers/Api/AssetsController.php +++ b/app/Http/Controllers/Api/AssetsController.php @@ -851,7 +851,6 @@ class AssetsController extends Controller 'asset_tag' => $asset->asset_tag, ]; - // This item is checked out to a location if (request('checkout_to_type') == 'location') { $target = Location::find(request('assigned_location')); @@ -878,13 +877,10 @@ class AssetsController extends Controller $asset->status_id = $request->get('status_id'); } - if (! isset($target)) { return response()->json(Helper::formatStandardApiResponse('error', $error_payload, 'Checkout target for asset '.e($asset->asset_tag).' is invalid - '.$error_payload['target_type'].' does not exist.')); } - - $checkout_at = request('checkout_at', date('Y-m-d H:i:s')); $expected_checkin = request('expected_checkin', null); $note = request('note', null); @@ -900,8 +896,6 @@ class AssetsController extends Controller // $asset->location_id = $target->rtd_location_id; // } - - if ($asset->checkOut($target, Auth::user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id)) { return response()->json(Helper::formatStandardApiResponse('success', ['asset'=> e($asset->asset_tag)], trans('admin/hardware/message.checkout.success'))); } diff --git a/app/Http/Requests/AssetCheckoutRequest.php b/app/Http/Requests/AssetCheckoutRequest.php index a4c6f8e00..f48a7d5e5 100644 --- a/app/Http/Requests/AssetCheckoutRequest.php +++ b/app/Http/Requests/AssetCheckoutRequest.php @@ -27,6 +27,14 @@ class AssetCheckoutRequest extends Request 'assigned_location' => 'required_without_all:assigned_user,assigned_asset', 'status_id' => 'exists:status_labels,id,deployable,1', 'checkout_to_type' => 'required|in:asset,location,user', + 'checkout_at' => [ + 'nullable', + 'date', + ], + 'expected_checkin' => [ + 'nullable', + 'date' + ], ]; return $rules; diff --git a/database/factories/LicenseSeatFactory.php b/database/factories/LicenseSeatFactory.php index cd9acfee1..f9560af47 100644 --- a/database/factories/LicenseSeatFactory.php +++ b/database/factories/LicenseSeatFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Models\Asset; use App\Models\License; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -15,6 +16,15 @@ class LicenseSeatFactory extends Factory ]; } + public function assignedToAsset(Asset $asset = null) + { + return $this->state(function () use ($asset) { + return [ + 'asset_id' => $asset->id ?? Asset::factory(), + ]; + }); + } + public function assignedToUser(User $user = null) { return $this->state(function () use ($user) { diff --git a/database/factories/StatuslabelFactory.php b/database/factories/StatuslabelFactory.php index 0b8359dd5..fa2e5d5e1 100644 --- a/database/factories/StatuslabelFactory.php +++ b/database/factories/StatuslabelFactory.php @@ -46,6 +46,11 @@ class StatuslabelFactory extends Factory }); } + public function readyToDeploy() + { + return $this->rtd(); + } + public function pending() { return $this->state(function () { diff --git a/database/migrations/2024_05_27_143554_add_parent_id_index_to_locations.php b/database/migrations/2024_05_27_143554_add_parent_id_index_to_locations.php new file mode 100644 index 000000000..2aef11918 --- /dev/null +++ b/database/migrations/2024_05_27_143554_add_parent_id_index_to_locations.php @@ -0,0 +1,33 @@ +index('parent_id'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('locations', function (Blueprint $table) { + // + $table->dropIndex('locations_parent_id_index'); + }); + } +} diff --git a/tests/Feature/Api/Assets/AssetCheckoutTest.php b/tests/Feature/Api/Assets/AssetCheckoutTest.php new file mode 100644 index 000000000..82d572ef5 --- /dev/null +++ b/tests/Feature/Api/Assets/AssetCheckoutTest.php @@ -0,0 +1,213 @@ +actingAsForApi(User::factory()->create()) + ->postJson(route('api.asset.checkout', Asset::factory()->create()), [ + 'checkout_to_type' => 'user', + 'assigned_user' => User::factory()->create()->id, + ]) + ->assertForbidden(); + } + + public function testNonExistentAssetCannotBeCheckedOut() + { + $this->actingAsForApi(User::factory()->checkoutAssets()->create()) + ->postJson(route('api.asset.checkout', 1000), [ + 'checkout_to_type' => 'user', + 'assigned_user' => User::factory()->create()->id, + ]) + ->assertStatusMessageIs('error'); + } + + public function testAssetNotAvailableForCheckoutCannotBeCheckedOut() + { + $assetAlreadyCheckedOut = Asset::factory()->assignedToUser()->create(); + + $this->actingAsForApi(User::factory()->checkoutAssets()->create()) + ->postJson(route('api.asset.checkout', $assetAlreadyCheckedOut), [ + 'checkout_to_type' => 'user', + 'assigned_user' => User::factory()->create()->id, + ]) + ->assertStatusMessageIs('error'); + } + + public function testAssetCannotBeCheckedOutToItself() + { + $asset = Asset::factory()->create(); + + $this->actingAsForApi(User::factory()->checkoutAssets()->create()) + ->postJson(route('api.asset.checkout', $asset), [ + 'checkout_to_type' => 'asset', + 'assigned_asset' => $asset->id, + ]) + ->assertStatusMessageIs('error'); + } + + public function testValidationWhenCheckingOutAsset() + { + $this->actingAsForApi(User::factory()->checkoutAssets()->create()) + ->postJson(route('api.asset.checkout', Asset::factory()->create()), []) + ->assertStatusMessageIs('error'); + + Event::assertNotDispatched(CheckoutableCheckedOut::class); + } + + public function testCannotCheckoutAcrossCompaniesWhenFullCompanySupportEnabled() + { + $this->markTestIncomplete('This is not implemented'); + } + + /** + * This data provider contains checkout targets along with the + * asset's expected location after the checkout process. + */ + public function checkoutTargets(): array + { + return [ + 'Checkout to User' => [ + function () { + $userLocation = Location::factory()->create(); + $user = User::factory()->for($userLocation)->create(); + + return [ + 'checkout_type' => 'user', + 'target' => $user, + 'expected_location' => $userLocation, + ]; + } + ], + 'Checkout to User without location set' => [ + function () { + $userLocation = Location::factory()->create(); + $user = User::factory()->for($userLocation)->create(['location_id' => null]); + + return [ + 'checkout_type' => 'user', + 'target' => $user, + 'expected_location' => null, + ]; + } + ], + 'Checkout to Asset with location set' => [ + function () { + $rtdLocation = Location::factory()->create(); + $location = Location::factory()->create(); + $asset = Asset::factory()->for($location)->for($rtdLocation, 'defaultLoc')->create(); + + return [ + 'checkout_type' => 'asset', + 'target' => $asset, + 'expected_location' => $location, + ]; + } + ], + 'Checkout to Asset without location set' => [ + function () { + $rtdLocation = Location::factory()->create(); + $asset = Asset::factory()->for($rtdLocation, 'defaultLoc')->create(['location_id' => null]); + + return [ + 'checkout_type' => 'asset', + 'target' => $asset, + 'expected_location' => null, + ]; + } + ], + 'Checkout to Location' => [ + function () { + $location = Location::factory()->create(); + + return [ + 'checkout_type' => 'location', + 'target' => $location, + 'expected_location' => $location, + ]; + } + ], + ]; + } + + /** @dataProvider checkoutTargets */ + public function testAssetCanBeCheckedOut($data) + { + ['checkout_type' => $type, 'target' => $target, 'expected_location' => $expectedLocation] = $data(); + + $newStatus = Statuslabel::factory()->readyToDeploy()->create(); + $asset = Asset::factory()->forLocation()->create(); + $admin = User::factory()->checkoutAssets()->create(); + + $this->actingAsForApi($admin) + ->postJson(route('api.asset.checkout', $asset), [ + 'checkout_to_type' => $type, + 'assigned_'.$type => $target->id, + 'status_id' => $newStatus->id, + 'checkout_at' => '2024-04-01', + 'expected_checkin' => '2024-04-08', + 'name' => 'Changed Name', + 'note' => 'Here is a cool note!', + ]) + ->assertOk(); + + $asset->refresh(); + $this->assertTrue($asset->assignedTo()->is($target)); + $this->assertEquals('Changed Name', $asset->name); + $this->assertTrue($asset->assetstatus->is($newStatus)); + $this->assertEquals('2024-04-01 00:00:00', $asset->last_checkout); + $this->assertEquals('2024-04-08 00:00:00', (string) $asset->expected_checkin); + + $expectedLocation + ? $this->assertTrue($asset->location->is($expectedLocation)) + : $this->assertNull($asset->location); + + Event::assertDispatched(CheckoutableCheckedOut::class, 1); + Event::assertDispatched(function (CheckoutableCheckedOut $event) use ($admin, $asset, $target) { + $this->assertTrue($event->checkoutable->is($asset)); + $this->assertTrue($event->checkedOutTo->is($target)); + $this->assertTrue($event->checkedOutBy->is($admin)); + $this->assertEquals('Here is a cool note!', $event->note); + + return true; + }); + } + + public function testLicenseSeatsAreAssignedToUserUponCheckout() + { + $this->markTestIncomplete('This is not implemented'); + } + + public function testLastCheckoutUsesCurrentDateIfNotProvided() + { + $asset = Asset::factory()->create(['last_checkout' => now()->subMonth()]); + + $this->actingAsForApi(User::factory()->checkoutAssets()->create()) + ->postJson(route('api.asset.checkout', $asset), [ + 'checkout_to_type' => 'user', + 'assigned_user' => User::factory()->create()->id, + ]); + + $asset->refresh(); + + $this->assertTrue(Carbon::parse($asset->last_checkout)->diffInSeconds(now()) < 2); + } +} diff --git a/tests/Feature/Checkouts/AssetCheckoutTest.php b/tests/Feature/Checkouts/AssetCheckoutTest.php new file mode 100644 index 000000000..fd0408b45 --- /dev/null +++ b/tests/Feature/Checkouts/AssetCheckoutTest.php @@ -0,0 +1,239 @@ +actingAs(User::factory()->create()) + ->post(route('hardware.checkout.store', Asset::factory()->create()), [ + 'checkout_to_type' => 'user', + 'assigned_user' => User::factory()->create()->id, + ]) + ->assertForbidden(); + } + + public function testNonExistentAssetCannotBeCheckedOut() + { + $this->actingAs(User::factory()->checkoutAssets()->create()) + ->post(route('hardware.checkout.store', 1000), [ + 'checkout_to_type' => 'user', + 'assigned_user' => User::factory()->create()->id, + 'name' => 'Changed Name', + ]) + ->assertSessionHas('error') + ->assertRedirect(route('hardware.index')); + + Event::assertNotDispatched(CheckoutableCheckedOut::class); + } + + public function testAssetNotAvailableForCheckoutCannotBeCheckedOut() + { + $assetAlreadyCheckedOut = Asset::factory()->assignedToUser()->create(); + + $this->actingAs(User::factory()->checkoutAssets()->create()) + ->post(route('hardware.checkout.store', $assetAlreadyCheckedOut), [ + 'checkout_to_type' => 'user', + 'assigned_user' => User::factory()->create()->id, + ]) + ->assertSessionHas('error') + ->assertRedirect(route('hardware.index')); + + Event::assertNotDispatched(CheckoutableCheckedOut::class); + } + + public function testAssetCannotBeCheckedOutToItself() + { + $asset = Asset::factory()->create(); + + $this->actingAs(User::factory()->checkoutAssets()->create()) + ->post(route('hardware.checkout.store', $asset), [ + 'checkout_to_type' => 'asset', + 'assigned_asset' => $asset->id, + ]) + ->assertSessionHas('error'); + + Event::assertNotDispatched(CheckoutableCheckedOut::class); + } + + public function testValidationWhenCheckingOutAsset() + { + $this->actingAs(User::factory()->create()) + ->post(route('hardware.checkout.store', Asset::factory()->create()), [ + 'status_id' => 'does-not-exist', + 'checkout_at' => 'invalid-date', + 'expected_checkin' => 'invalid-date', + ]) + ->assertSessionHasErrors([ + 'assigned_user', + 'assigned_asset', + 'assigned_location', + 'status_id', + 'checkout_to_type', + 'checkout_at', + 'expected_checkin', + ]); + + Event::assertNotDispatched(CheckoutableCheckedOut::class); + } + + public function testCannotCheckoutAcrossCompaniesWhenFullCompanySupportEnabled() + { + $this->settings->enableMultipleFullCompanySupport(); + + $assetCompany = Company::factory()->create(); + $userCompany = Company::factory()->create(); + + $user = User::factory()->for($userCompany)->create(); + $asset = Asset::factory()->for($assetCompany)->create(); + + $this->actingAs(User::factory()->superuser()->create()) + ->post(route('hardware.checkout.store', $asset), [ + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ]) + ->assertRedirect(route('hardware.checkout.store', $asset)); + + Event::assertNotDispatched(CheckoutableCheckedOut::class); + } + + /** + * This data provider contains checkout targets along with the + * asset's expected location after the checkout process. + */ + public function checkoutTargets(): array + { + return [ + 'User' => [function () { + $userLocation = Location::factory()->create(); + $user = User::factory()->for($userLocation)->create(); + + return [ + 'checkout_type' => 'user', + 'target' => $user, + 'expected_location' => $userLocation, + ]; + }], + 'Asset without location set' => [function () { + $rtdLocation = Location::factory()->create(); + $asset = Asset::factory()->for($rtdLocation, 'defaultLoc')->create(['location_id' => null]); + + return [ + 'checkout_type' => 'asset', + 'target' => $asset, + 'expected_location' => $rtdLocation, + ]; + }], + 'Asset with location set' => [function () { + $rtdLocation = Location::factory()->create(); + $location = Location::factory()->create(); + $asset = Asset::factory()->for($location)->for($rtdLocation, 'defaultLoc')->create(); + + return [ + 'checkout_type' => 'asset', + 'target' => $asset, + 'expected_location' => $location, + ]; + }], + 'Location' => [function () { + $location = Location::factory()->create(); + + return [ + 'checkout_type' => 'location', + 'target' => $location, + 'expected_location' => $location, + ]; + }], + ]; + } + + /** @dataProvider checkoutTargets */ + public function testAssetCanBeCheckedOut($data) + { + ['checkout_type' => $type, 'target' => $target, 'expected_location' => $expectedLocation] = $data(); + + $newStatus = Statuslabel::factory()->readyToDeploy()->create(); + $asset = Asset::factory()->create(); + $admin = User::factory()->checkoutAssets()->create(); + + $this->actingAs($admin) + ->post(route('hardware.checkout.store', $asset), [ + 'checkout_to_type' => $type, + 'assigned_' . $type => $target->id, + 'name' => 'Changed Name', + 'status_id' => $newStatus->id, + 'checkout_at' => '2024-03-18', + 'expected_checkin' => '2024-03-28', + 'note' => 'An awesome note', + ]); + + $asset->refresh(); + $this->assertTrue($asset->assignedTo()->is($target)); + $this->assertTrue($asset->location->is($expectedLocation)); + $this->assertEquals('Changed Name', $asset->name); + $this->assertTrue($asset->assetstatus->is($newStatus)); + $this->assertEquals('2024-03-18 00:00:00', $asset->last_checkout); + $this->assertEquals('2024-03-28 00:00:00', (string)$asset->expected_checkin); + + Event::assertDispatched(CheckoutableCheckedOut::class, 1); + Event::assertDispatched(function (CheckoutableCheckedOut $event) use ($admin, $asset, $target) { + $this->assertTrue($event->checkoutable->is($asset)); + $this->assertTrue($event->checkedOutTo->is($target)); + $this->assertTrue($event->checkedOutBy->is($admin)); + $this->assertEquals('An awesome note', $event->note); + + return true; + }); + } + + public function testLicenseSeatsAreAssignedToUserUponCheckout() + { + $asset = Asset::factory()->create(); + $seat = LicenseSeat::factory()->assignedToAsset($asset)->create(); + $user = User::factory()->create(); + + $this->assertFalse($user->licenses->contains($seat->license)); + + $this->actingAs(User::factory()->checkoutAssets()->create()) + ->post(route('hardware.checkout.store', $asset), [ + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ]); + + $this->assertTrue($user->fresh()->licenses->contains($seat->license)); + } + + public function testLastCheckoutUsesCurrentDateIfNotProvided() + { + $asset = Asset::factory()->create(['last_checkout' => now()->subMonth()]); + + $this->actingAs(User::factory()->checkoutAssets()->create()) + ->post(route('hardware.checkout.store', $asset), [ + 'checkout_to_type' => 'user', + 'assigned_user' => User::factory()->create()->id, + ]); + + $asset->refresh(); + + $this->assertTrue(Carbon::parse($asset->last_checkout)->diffInSeconds(now()) < 2); + } +} diff --git a/tests/Unit/Listeners/LogListenerTest.php b/tests/Unit/Listeners/LogListenerTest.php new file mode 100644 index 000000000..011a5c51a --- /dev/null +++ b/tests/Unit/Listeners/LogListenerTest.php @@ -0,0 +1,39 @@ +create(); + $checkedOutTo = User::factory()->create(); + $checkedOutBy = User::factory()->create(); + + // Simply to ensure `user_id` is set in the action log + $this->actingAs($checkedOutBy); + + (new LogListener())->onCheckoutableCheckedOut(new CheckoutableCheckedOut( + $asset, + $checkedOutTo, + $checkedOutBy, + 'A simple note...', + )); + + $this->assertDatabaseHas('action_logs', [ + 'action_type' => 'checkout', + 'user_id' => $checkedOutBy->id, + 'target_id' => $checkedOutTo->id, + 'target_type' => User::class, + 'item_id' => $asset->id, + 'item_type' => Asset::class, + 'note' => 'A simple note...', + ]); + } +}