3
0
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:
snipe
2026-07-22 17:16:35 +01:00
parent 27369e04b3
commit 73e2575e98
7 changed files with 833 additions and 0 deletions

View 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));
}
}

View 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')
);
}
});
}
}

View File

@ -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.',
],
];

View 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

View File

@ -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">

View File

@ -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, [

View 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();
}
}