mirror of
https://github.com/snipe/snipe-it.git
synced 2026-08-18 11:15:42 +00:00
Added transfer from one user to another
This commit is contained in:
231
app/Http/Controllers/Users/UserItemTransferController.php
Normal file
231
app/Http/Controllers/Users/UserItemTransferController.php
Normal file
@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Users;
|
||||
|
||||
use App\Events\CheckoutableCheckedIn;
|
||||
use App\Events\CheckoutableCheckedOut;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\TransferUserItemsRequest;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UserItemTransferController extends Controller
|
||||
{
|
||||
public function show(User $user): View|RedirectResponse
|
||||
{
|
||||
$this->authorize('view', $user);
|
||||
$this->authorize('checkin', Asset::class);
|
||||
$this->authorize('checkout', Asset::class);
|
||||
|
||||
$assets = $user->assets()
|
||||
->with(['model.category', 'company'])
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
|
||||
$accessoryCheckouts = AccessoryCheckout::with(['accessory.category', 'accessory.company'])
|
||||
->where('assigned_to', $user->id)
|
||||
->where('assigned_type', User::class)
|
||||
->get();
|
||||
|
||||
$licenseSeats = LicenseSeat::with(['license.category', 'license.company'])
|
||||
->where('assigned_to', $user->id)
|
||||
->whereNull('asset_id')
|
||||
->get();
|
||||
|
||||
if ($assets->isEmpty() && $accessoryCheckouts->isEmpty() && $licenseSeats->isEmpty()) {
|
||||
return redirect()->route('users.show', $user)
|
||||
->with('error', trans('admin/users/general.transfer.nothing_to_transfer'));
|
||||
}
|
||||
|
||||
return view('users.transfer', [
|
||||
'sourceUser' => $user,
|
||||
'assets' => $assets,
|
||||
'accessoryCheckouts' => $accessoryCheckouts,
|
||||
'licenseSeats' => $licenseSeats,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(TransferUserItemsRequest $request, User $user): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$target = User::findOrFail($validated['target_user_id']);
|
||||
|
||||
$assetIds = $validated['asset_ids'] ?? [];
|
||||
$accessoryCheckoutIds = $validated['accessory_checkout_ids'] ?? [];
|
||||
$licenseSeatIds = $validated['license_seat_ids'] ?? [];
|
||||
$note = $validated['note'];
|
||||
|
||||
$result = DB::transaction(function () use ($user, $target, $assetIds, $accessoryCheckoutIds, $licenseSeatIds, $note) {
|
||||
$assetsTransferred = 0;
|
||||
$accessoriesTransferred = 0;
|
||||
$licensesTransferred = 0;
|
||||
$skipped = [];
|
||||
|
||||
foreach ($assetIds as $assetId) {
|
||||
$asset = Asset::find($assetId);
|
||||
|
||||
if (! $asset || (int) $asset->assigned_to !== (int) $user->id || $asset->assigned_type !== User::class) {
|
||||
$skipped[] = 'asset:'.$assetId;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $asset->canCheckoutTo($target)) {
|
||||
$skipped[] = 'asset:'.$assetId;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->checkInAsset($asset, $user, $note);
|
||||
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), null, $note);
|
||||
$assetsTransferred++;
|
||||
}
|
||||
|
||||
foreach ($accessoryCheckoutIds as $checkoutId) {
|
||||
$checkout = AccessoryCheckout::with('accessory')->find($checkoutId);
|
||||
|
||||
if (! $checkout || (int) $checkout->assigned_to !== (int) $user->id || $checkout->assigned_type !== User::class) {
|
||||
$skipped[] = 'accessory:'.$checkoutId;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$accessory = $checkout->accessory;
|
||||
|
||||
if (! $accessory || ! $accessory->canCheckoutTo($target)) {
|
||||
$skipped[] = 'accessory:'.$checkoutId;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->checkInAccessory($checkout, $accessory, $note);
|
||||
$this->checkOutAccessory($accessory, $target, $note);
|
||||
$accessoriesTransferred++;
|
||||
}
|
||||
|
||||
foreach ($licenseSeatIds as $seatId) {
|
||||
$seat = LicenseSeat::with('license')->find($seatId);
|
||||
|
||||
if (! $seat || (int) $seat->assigned_to !== (int) $user->id || $seat->asset_id !== null) {
|
||||
$skipped[] = 'license:'.$seatId;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$license = $seat->license;
|
||||
|
||||
// Non-reassignable licenses stay with the original assignee.
|
||||
// Transferring one would defeat the whole point of the flag,
|
||||
// so we skip and surface it in the warning bucket.
|
||||
if (! $license || ! $license->reassignable || ! $license->canCheckoutTo($target)) {
|
||||
$skipped[] = 'license:'.$seatId;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->transferLicenseSeat($seat, $user, $target, $note);
|
||||
$licensesTransferred++;
|
||||
}
|
||||
|
||||
return compact('assetsTransferred', 'accessoriesTransferred', 'licensesTransferred', 'skipped');
|
||||
});
|
||||
|
||||
$flash = trans('admin/users/general.transfer.success', [
|
||||
'assets' => $result['assetsTransferred'],
|
||||
'accessories' => $result['accessoriesTransferred'],
|
||||
'licenses' => $result['licensesTransferred'],
|
||||
'target' => $target->display_name,
|
||||
]);
|
||||
|
||||
$redirect = redirect()->route('users.show', $target)->with('success', $flash);
|
||||
|
||||
if (! empty($result['skipped'])) {
|
||||
$redirect->with('warning', trans('admin/users/general.transfer.some_skipped', [
|
||||
'count' => count($result['skipped']),
|
||||
]));
|
||||
}
|
||||
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
private function checkInAsset(Asset $asset, User $source, ?string $note): void
|
||||
{
|
||||
$originalValues = $asset->getRawOriginal();
|
||||
$checkinAt = date('Y-m-d H:i:s');
|
||||
|
||||
$asset->expected_checkin = null;
|
||||
$asset->last_checkin = $checkinAt;
|
||||
$asset->accepted = null;
|
||||
$asset->assignedTo()->dissociate();
|
||||
|
||||
$asset->licenseseats->each(function (LicenseSeat $seat) {
|
||||
$seat->update(['assigned_to' => null]);
|
||||
});
|
||||
|
||||
CheckoutAcceptance::pending()
|
||||
->where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->get()
|
||||
->each(fn ($a) => $a->delete());
|
||||
|
||||
$asset->save();
|
||||
|
||||
event(new CheckoutableCheckedIn($asset, $source, auth()->user(), $note, $checkinAt, $originalValues));
|
||||
}
|
||||
|
||||
private function checkInAccessory(AccessoryCheckout $checkout, Accessory $accessory, ?string $note): void
|
||||
{
|
||||
$source = $checkout->assignedTo;
|
||||
|
||||
CheckoutAcceptance::pending()
|
||||
->where('checkoutable_type', Accessory::class)
|
||||
->where('checkoutable_id', $accessory->id)
|
||||
->where('assigned_to_id', $checkout->assigned_to)
|
||||
->get()
|
||||
->each(fn ($a) => $a->delete());
|
||||
|
||||
$checkout->delete();
|
||||
|
||||
event(new CheckoutableCheckedIn($accessory, $source, auth()->user(), $note, date('Y-m-d H:i:s')));
|
||||
}
|
||||
|
||||
private function checkOutAccessory(Accessory $accessory, User $target, ?string $note): void
|
||||
{
|
||||
$newCheckout = new AccessoryCheckout([
|
||||
'accessory_id' => $accessory->id,
|
||||
'assigned_to' => $target->id,
|
||||
'assigned_type' => User::class,
|
||||
'note' => $note,
|
||||
]);
|
||||
$newCheckout->created_by = auth()->id();
|
||||
$newCheckout->save();
|
||||
|
||||
event(new CheckoutableCheckedOut($accessory, $target, auth()->user(), $note, [], 1, false));
|
||||
}
|
||||
|
||||
private function transferLicenseSeat(LicenseSeat $seat, User $source, User $target, ?string $note): void
|
||||
{
|
||||
CheckoutAcceptance::pending()
|
||||
->where('checkoutable_type', License::class)
|
||||
->where('checkoutable_id', $seat->license_id)
|
||||
->where('assigned_to_id', $source->id)
|
||||
->get()
|
||||
->each(fn ($a) => $a->delete());
|
||||
|
||||
$seat->assigned_to = null;
|
||||
$seat->save();
|
||||
event(new CheckoutableCheckedIn($seat, $source, auth()->user(), $note));
|
||||
|
||||
$seat->assigned_to = $target->id;
|
||||
$seat->save();
|
||||
event(new CheckoutableCheckedOut($seat, $target, auth()->user(), $note, [], 1, false));
|
||||
}
|
||||
}
|
||||
59
app/Http/Requests/TransferUserItemsRequest.php
Normal file
59
app/Http/Requests/TransferUserItemsRequest.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Asset;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TransferUserItemsRequest extends Request
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$sourceUser = $this->route('user');
|
||||
|
||||
return $sourceUser
|
||||
&& Gate::allows('view', $sourceUser)
|
||||
&& Gate::allows('checkin', Asset::class)
|
||||
&& Gate::allows('checkout', Asset::class);
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'target_user_id' => ['required', 'integer', Rule::exists('users', 'id')->whereNull('deleted_at')],
|
||||
'asset_ids' => ['nullable', 'array'],
|
||||
'asset_ids.*' => ['integer'],
|
||||
'accessory_checkout_ids' => ['nullable', 'array'],
|
||||
'accessory_checkout_ids.*' => ['integer'],
|
||||
'license_seat_ids' => ['nullable', 'array'],
|
||||
'license_seat_ids.*' => ['integer'],
|
||||
'note' => ['required', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator) {
|
||||
$sourceUser = $this->route('user');
|
||||
if ($sourceUser && (int) $this->input('target_user_id') === (int) $sourceUser->id) {
|
||||
$validator->errors()->add(
|
||||
'target_user_id',
|
||||
trans('admin/users/general.transfer.target_same_as_source')
|
||||
);
|
||||
}
|
||||
|
||||
$assetIds = (array) $this->input('asset_ids', []);
|
||||
$accessoryIds = (array) $this->input('accessory_checkout_ids', []);
|
||||
$licenseSeatIds = (array) $this->input('license_seat_ids', []);
|
||||
|
||||
if (empty($assetIds) && empty($accessoryIds) && empty($licenseSeatIds)) {
|
||||
$validator->errors()->add(
|
||||
'asset_ids',
|
||||
trans('admin/users/general.transfer.nothing_selected')
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -74,4 +74,24 @@ return [
|
||||
'impersonating_banner_title' => 'Impersonating:',
|
||||
'impersonating_banner_text' => 'You are currently logged in as :name. Anything you do will be recorded as if :name did it. Your real account is :impersonator.',
|
||||
'impersonating_stop_link' => 'Switch back to :name',
|
||||
'transfer' => [
|
||||
'title' => 'Transfer Items',
|
||||
'button' => 'Transfer Items',
|
||||
'button_tooltip' => 'Check in and re-check out this user\'s items to another user in one step.',
|
||||
'heading' => 'Transfer items from :name',
|
||||
'intro' => 'Pick a destination user and confirm which items to transfer. Each selected item is checked in from the source user and immediately checked out to the destination. Items requiring acceptance will re-fire the acceptance flow for the destination user.',
|
||||
'target_user' => 'Transfer to',
|
||||
'assets' => 'Assets',
|
||||
'accessories' => 'Accessories',
|
||||
'licenses' => 'Licenses',
|
||||
'non_reassignable' => 'Non-reassignable',
|
||||
'note' => 'Reason for transfer',
|
||||
'note_help' => 'A short reason for this transfer, recorded on both the checkin and checkout audit-log entries. Required so future readers can see why the items moved.',
|
||||
'submit' => 'Transfer selected items',
|
||||
'nothing_to_transfer' => 'This user has no assets, accessories, or licenses checked out to transfer.',
|
||||
'nothing_selected' => 'Select at least one item to transfer.',
|
||||
'target_same_as_source' => 'The destination user must be different from the source user.',
|
||||
'success' => 'Transferred :assets asset(s), :accessories accessory checkout(s), and :licenses license seat(s) to :target.',
|
||||
'some_skipped' => ':count item(s) were skipped, likely because they no longer belong to the source user, violate company scoping, or are non-reassignable.',
|
||||
],
|
||||
];
|
||||
|
||||
213
resources/views/users/transfer.blade.php
Normal file
213
resources/views/users/transfer.blade.php
Normal file
@ -0,0 +1,213 @@
|
||||
@extends('layouts/default')
|
||||
|
||||
@section('title')
|
||||
{{ trans('admin/users/general.transfer.title') }}
|
||||
@parent
|
||||
@stop
|
||||
|
||||
@section('header_right')
|
||||
<x-button.info-panel-toggle hide-on-xs/>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
|
||||
<x-container columns="2">
|
||||
<x-page-column class="col-md-7">
|
||||
|
||||
<x-form
|
||||
id="transfer_form"
|
||||
:route="route('users.transfer.store', $sourceUser)"
|
||||
>
|
||||
|
||||
<x-box header="{{ trans('admin/users/general.transfer.heading', ['name' => $sourceUser->display_name]) }}">
|
||||
|
||||
<p>{{ trans('admin/users/general.transfer.intro') }}</p>
|
||||
|
||||
<x-input.user-select
|
||||
:label="trans('admin/users/general.transfer.target_user')"
|
||||
name="target_user_id"
|
||||
:selected="old('target_user_id')"
|
||||
:excludeId="$sourceUser->id"
|
||||
:required="true"
|
||||
/>
|
||||
|
||||
@if ($assets->isNotEmpty())
|
||||
<x-form.row
|
||||
:label="trans('admin/users/general.transfer.assets')"
|
||||
name="asset_ids"
|
||||
input_div_class="col-md-9"
|
||||
>
|
||||
<x-slot:input>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-md-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="{{ trans('general.select_all') }}"
|
||||
data-toggle="check-all"
|
||||
checked
|
||||
/>
|
||||
</th>
|
||||
<th>{{ trans('general.asset_tag') }}</th>
|
||||
<th>{{ trans('general.name') }}</th>
|
||||
<th>{{ trans('general.category') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($assets as $asset)
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="asset_ids[]"
|
||||
value="{{ $asset->id }}"
|
||||
aria-label="{{ $asset->asset_tag }}"
|
||||
checked
|
||||
/>
|
||||
</td>
|
||||
<td>{{ $asset->asset_tag }}</td>
|
||||
<td>{{ $asset->name ?: ($asset->model->name ?? '') }}</td>
|
||||
<td>{{ $asset->model?->category?->name }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</x-slot:input>
|
||||
</x-form.row>
|
||||
@endif
|
||||
|
||||
@if ($accessoryCheckouts->isNotEmpty())
|
||||
<x-form.row
|
||||
:label="trans('admin/users/general.transfer.accessories')"
|
||||
name="accessory_checkout_ids"
|
||||
input_div_class="col-md-9"
|
||||
>
|
||||
<x-slot:input>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-md-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="{{ trans('general.select_all') }}"
|
||||
data-toggle="check-all"
|
||||
checked
|
||||
/>
|
||||
</th>
|
||||
<th>{{ trans('general.name') }}</th>
|
||||
<th>{{ trans('general.category') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($accessoryCheckouts as $checkout)
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="accessory_checkout_ids[]"
|
||||
value="{{ $checkout->id }}"
|
||||
aria-label="{{ $checkout->accessory?->name }}"
|
||||
checked
|
||||
/>
|
||||
</td>
|
||||
<td>{{ $checkout->accessory?->name }}</td>
|
||||
<td>{{ $checkout->accessory?->category?->name }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</x-slot:input>
|
||||
</x-form.row>
|
||||
@endif
|
||||
|
||||
@if ($licenseSeats->isNotEmpty())
|
||||
<x-form.row
|
||||
:label="trans('admin/users/general.transfer.licenses')"
|
||||
name="license_seat_ids"
|
||||
input_div_class="col-md-9"
|
||||
>
|
||||
<x-slot:input>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-md-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="{{ trans('general.select_all') }}"
|
||||
data-toggle="check-all"
|
||||
checked
|
||||
/>
|
||||
</th>
|
||||
<th>{{ trans('general.name') }}</th>
|
||||
<th>{{ trans('general.category') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($licenseSeats as $seat)
|
||||
<tr @class(['text-muted' => ! $seat->license?->reassignable])>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="license_seat_ids[]"
|
||||
value="{{ $seat->id }}"
|
||||
aria-label="{{ $seat->license?->name }}"
|
||||
@checked($seat->license?->reassignable)
|
||||
@disabled(! $seat->license?->reassignable)
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{{ $seat->license?->name }}
|
||||
@if ($seat->license && ! $seat->license->reassignable)
|
||||
<span class="label label-default">{{ trans('admin/users/general.transfer.non_reassignable') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $seat->license?->category?->name }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</x-slot:input>
|
||||
</x-form.row>
|
||||
@endif
|
||||
|
||||
<x-form.row
|
||||
:label="trans('admin/users/general.transfer.note')"
|
||||
name="note"
|
||||
:help_text="trans('admin/users/general.transfer.note_help')"
|
||||
>
|
||||
<x-slot:input>
|
||||
<textarea
|
||||
id="note"
|
||||
name="note"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
maxlength="1000"
|
||||
aria-describedby="note-help"
|
||||
required
|
||||
>{{ old('note') }}</textarea>
|
||||
</x-slot:input>
|
||||
</x-form.row>
|
||||
|
||||
</x-box>
|
||||
|
||||
<x-slot:footer>
|
||||
<a href="{{ route('users.show', $sourceUser) }}" class="btn btn-link">{{ trans('button.cancel') }}</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<x-icon type="checkout" class="fa-fw" />
|
||||
{{ trans('admin/users/general.transfer.submit') }}
|
||||
</button>
|
||||
</x-slot:footer>
|
||||
|
||||
</x-form>
|
||||
|
||||
</x-page-column>
|
||||
|
||||
<x-page-column class="col-md-5">
|
||||
<livewire:checkout-target-panel type="assets" defaultTargetType="user" />
|
||||
<livewire:checkout-target-panel type="accessories" defaultTargetType="user" />
|
||||
<livewire:checkout-target-panel type="licenses" defaultTargetType="user" />
|
||||
</x-page-column>
|
||||
</x-container>
|
||||
|
||||
@stop
|
||||
@ -616,6 +616,14 @@
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@can('checkout', \App\Models\Asset::class)
|
||||
@if (($user->assets()->whereNull('deleted_at')->count() + $user->accessories()->count() + $user->licenses()->count()) > 0)
|
||||
<a href="{{ route('users.transfer.show', $user) }}" class="btn btn-sm btn-theme hidden-print" data-tooltip="true" data-title="{{ trans('admin/users/general.transfer.button_tooltip') }}">
|
||||
<x-icon type="checkout" class="fa-fw"/>
|
||||
</a>
|
||||
@endif
|
||||
@endcan
|
||||
|
||||
|
||||
@if(!empty($user->email) && ($user->allAssignedCount() != '0'))
|
||||
<form class="form-inline" style="display: inline" action="{{ route('users.email',['userId'=> $user->id]) }}" method="POST">
|
||||
|
||||
@ -161,6 +161,19 @@ Route::group(['prefix' => 'users', 'middleware' => ['auth']], function () {
|
||||
]
|
||||
)->name('users/bulkeditsave');
|
||||
|
||||
Route::get(
|
||||
'{user}/transfer',
|
||||
[Users\UserItemTransferController::class, 'show'],
|
||||
)->name('users.transfer.show')
|
||||
->breadcrumbs(fn (Trail $trail, $user) => $trail
|
||||
->parent('users.show', $user)
|
||||
->push(trans('admin/users/general.transfer.title'), route('users.transfer.show', $user)));
|
||||
|
||||
Route::post(
|
||||
'{user}/transfer',
|
||||
[Users\UserItemTransferController::class, 'store'],
|
||||
)->name('users.transfer.store');
|
||||
|
||||
});
|
||||
|
||||
Route::resource('users', Users\UsersController::class, [
|
||||
|
||||
289
tests/Feature/Users/TransferUserItemsTest.php
Normal file
289
tests/Feature/Users/TransferUserItemsTest.php
Normal file
@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Users;
|
||||
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\Asset;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\User;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TransferUserItemsTest extends TestCase
|
||||
{
|
||||
public function test_transfer_page_requires_authentication(): void
|
||||
{
|
||||
User::factory()->create();
|
||||
$source = User::factory()->create();
|
||||
|
||||
$this->get(route('users.transfer.show', $source))
|
||||
->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_transfer_page_requires_checkout_permission(): void
|
||||
{
|
||||
$source = User::factory()->create();
|
||||
|
||||
$this->actingAs(User::factory()->viewUsers()->create())
|
||||
->get(route('users.transfer.show', $source))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_transfer_page_renders_when_source_has_items(): void
|
||||
{
|
||||
$source = User::factory()->create();
|
||||
Asset::factory()->create(['assigned_to' => $source->id, 'assigned_type' => User::class]);
|
||||
|
||||
$this->actingAs($this->transferActor())
|
||||
->get(route('users.transfer.show', $source))
|
||||
->assertOk()
|
||||
->assertViewIs('users.transfer');
|
||||
}
|
||||
|
||||
public function test_transfer_page_redirects_when_source_has_no_items(): void
|
||||
{
|
||||
$source = User::factory()->create();
|
||||
|
||||
$this->actingAs($this->transferActor())
|
||||
->get(route('users.transfer.show', $source))
|
||||
->assertRedirect(route('users.show', $source));
|
||||
}
|
||||
|
||||
public function test_transfer_moves_asset_from_source_to_target(): void
|
||||
{
|
||||
$source = User::factory()->create();
|
||||
$target = User::factory()->create();
|
||||
$asset = Asset::factory()->create([
|
||||
'assigned_to' => $source->id,
|
||||
'assigned_type' => User::class,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->transferActor())
|
||||
->post(route('users.transfer.store', $source), [
|
||||
'target_user_id' => $target->id,
|
||||
'asset_ids' => [$asset->id],
|
||||
'note' => 'employee offboarding',
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('users.show', $target));
|
||||
|
||||
$asset->refresh();
|
||||
$this->assertSame($target->id, $asset->assigned_to);
|
||||
$this->assertSame(User::class, $asset->assigned_type);
|
||||
|
||||
// Both a checkin and a checkout should be logged for this transfer
|
||||
// so the audit trail shows the full move rather than a single event.
|
||||
$this->assertDatabaseHas('action_logs', [
|
||||
'action_type' => 'checkin from',
|
||||
'target_id' => $source->id,
|
||||
'target_type' => User::class,
|
||||
'item_id' => $asset->id,
|
||||
'item_type' => Asset::class,
|
||||
]);
|
||||
$this->assertDatabaseHas('action_logs', [
|
||||
'action_type' => 'checkout',
|
||||
'target_id' => $target->id,
|
||||
'target_type' => User::class,
|
||||
'item_id' => $asset->id,
|
||||
'item_type' => Asset::class,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_transfer_moves_accessory_from_source_to_target(): void
|
||||
{
|
||||
$source = User::factory()->create();
|
||||
$target = User::factory()->create();
|
||||
$accessory = Accessory::factory()->create();
|
||||
$checkout = AccessoryCheckout::create([
|
||||
'accessory_id' => $accessory->id,
|
||||
'assigned_to' => $source->id,
|
||||
'assigned_type' => User::class,
|
||||
'created_by' => User::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->transferActor())
|
||||
->post(route('users.transfer.store', $source), [
|
||||
'target_user_id' => $target->id,
|
||||
'accessory_checkout_ids' => [$checkout->id],
|
||||
'note' => 'employee offboarding',
|
||||
])
|
||||
->assertRedirect(route('users.show', $target));
|
||||
|
||||
$this->assertDatabaseMissing('accessories_checkout', ['id' => $checkout->id]);
|
||||
$this->assertDatabaseHas('accessories_checkout', [
|
||||
'accessory_id' => $accessory->id,
|
||||
'assigned_to' => $target->id,
|
||||
'assigned_type' => User::class,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_transfer_moves_reassignable_license_seat_from_source_to_target(): void
|
||||
{
|
||||
$source = User::factory()->create();
|
||||
$target = User::factory()->create();
|
||||
$license = License::factory()->create(['reassignable' => 1]);
|
||||
$seat = LicenseSeat::factory()->assignedToUser($source)->create(['license_id' => $license->id]);
|
||||
|
||||
$this->actingAs($this->transferActor())
|
||||
->post(route('users.transfer.store', $source), [
|
||||
'target_user_id' => $target->id,
|
||||
'license_seat_ids' => [$seat->id],
|
||||
'note' => 'transferring license seat',
|
||||
])
|
||||
->assertRedirect(route('users.show', $target));
|
||||
|
||||
$seat->refresh();
|
||||
$this->assertSame($target->id, $seat->assigned_to);
|
||||
}
|
||||
|
||||
public function test_transfer_skips_non_reassignable_license_seat(): void
|
||||
{
|
||||
$source = User::factory()->create();
|
||||
$target = User::factory()->create();
|
||||
$license = License::factory()->create(['reassignable' => 0]);
|
||||
$seat = LicenseSeat::factory()->assignedToUser($source)->create(['license_id' => $license->id]);
|
||||
|
||||
// Non-reassignable licenses stay put by design. The seat must NOT
|
||||
// move even if the client somehow submits its id.
|
||||
$this->actingAs($this->transferActor())
|
||||
->post(route('users.transfer.store', $source), [
|
||||
'target_user_id' => $target->id,
|
||||
'license_seat_ids' => [$seat->id],
|
||||
'note' => 'attempt to move non-reassignable license',
|
||||
])
|
||||
->assertRedirect(route('users.show', $target));
|
||||
|
||||
$seat->refresh();
|
||||
$this->assertSame($source->id, $seat->assigned_to);
|
||||
}
|
||||
|
||||
public function test_transfer_leaves_unselected_items_alone(): void
|
||||
{
|
||||
$source = User::factory()->create();
|
||||
$target = User::factory()->create();
|
||||
|
||||
$selectedAsset = Asset::factory()->create([
|
||||
'assigned_to' => $source->id,
|
||||
'assigned_type' => User::class,
|
||||
]);
|
||||
$unselectedAsset = Asset::factory()->create([
|
||||
'assigned_to' => $source->id,
|
||||
'assigned_type' => User::class,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->transferActor())
|
||||
->post(route('users.transfer.store', $source), [
|
||||
'target_user_id' => $target->id,
|
||||
'asset_ids' => [$selectedAsset->id],
|
||||
'note' => 'transferring one item',
|
||||
]);
|
||||
|
||||
$selectedAsset->refresh();
|
||||
$unselectedAsset->refresh();
|
||||
|
||||
$this->assertSame($target->id, $selectedAsset->assigned_to);
|
||||
$this->assertSame($source->id, $unselectedAsset->assigned_to);
|
||||
}
|
||||
|
||||
public function test_transfer_rejects_same_source_and_target(): void
|
||||
{
|
||||
$source = User::factory()->create();
|
||||
$asset = Asset::factory()->create([
|
||||
'assigned_to' => $source->id,
|
||||
'assigned_type' => User::class,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->transferActor())
|
||||
->from(route('users.transfer.show', $source))
|
||||
->post(route('users.transfer.store', $source), [
|
||||
'target_user_id' => $source->id,
|
||||
'asset_ids' => [$asset->id],
|
||||
'note' => 'accidental self-target',
|
||||
])
|
||||
->assertRedirect(route('users.transfer.show', $source))
|
||||
->assertSessionHasErrors('target_user_id');
|
||||
|
||||
$asset->refresh();
|
||||
$this->assertSame($source->id, $asset->assigned_to);
|
||||
}
|
||||
|
||||
public function test_transfer_rejects_empty_selection(): void
|
||||
{
|
||||
$source = User::factory()->create();
|
||||
$target = User::factory()->create();
|
||||
|
||||
$this->actingAs($this->transferActor())
|
||||
->from(route('users.transfer.show', $source))
|
||||
->post(route('users.transfer.store', $source), [
|
||||
'target_user_id' => $target->id,
|
||||
'note' => 'nothing selected',
|
||||
])
|
||||
->assertRedirect(route('users.transfer.show', $source))
|
||||
->assertSessionHasErrors('asset_ids');
|
||||
}
|
||||
|
||||
public function test_transfer_rejects_empty_note(): void
|
||||
{
|
||||
$source = User::factory()->create();
|
||||
$target = User::factory()->create();
|
||||
$asset = Asset::factory()->create([
|
||||
'assigned_to' => $source->id,
|
||||
'assigned_type' => User::class,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->transferActor())
|
||||
->from(route('users.transfer.show', $source))
|
||||
->post(route('users.transfer.store', $source), [
|
||||
'target_user_id' => $target->id,
|
||||
'asset_ids' => [$asset->id],
|
||||
// note deliberately omitted
|
||||
])
|
||||
->assertRedirect(route('users.transfer.show', $source))
|
||||
->assertSessionHasErrors('note');
|
||||
|
||||
$asset->refresh();
|
||||
$this->assertSame($source->id, $asset->assigned_to);
|
||||
}
|
||||
|
||||
public function test_transfer_ignores_asset_not_actually_assigned_to_source(): void
|
||||
{
|
||||
// Someone tampering with the form to pass an asset ID that
|
||||
// doesn't belong to the source user should not be able to
|
||||
// hijack the transfer flow to reassign arbitrary assets.
|
||||
$source = User::factory()->create();
|
||||
$target = User::factory()->create();
|
||||
$otherOwner = User::factory()->create();
|
||||
|
||||
$sourceAsset = Asset::factory()->create([
|
||||
'assigned_to' => $source->id,
|
||||
'assigned_type' => User::class,
|
||||
]);
|
||||
$foreignAsset = Asset::factory()->create([
|
||||
'assigned_to' => $otherOwner->id,
|
||||
'assigned_type' => User::class,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->transferActor())
|
||||
->post(route('users.transfer.store', $source), [
|
||||
'target_user_id' => $target->id,
|
||||
'asset_ids' => [$sourceAsset->id, $foreignAsset->id],
|
||||
'note' => 'attempting cross-user transfer',
|
||||
]);
|
||||
|
||||
$sourceAsset->refresh();
|
||||
$foreignAsset->refresh();
|
||||
|
||||
$this->assertSame($target->id, $sourceAsset->assigned_to);
|
||||
$this->assertSame($otherOwner->id, $foreignAsset->assigned_to);
|
||||
}
|
||||
|
||||
private function transferActor(): User
|
||||
{
|
||||
return User::factory()
|
||||
->viewUsers()
|
||||
->checkinAssets()
|
||||
->checkoutAssets()
|
||||
->create();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user