Refactored licenses controller to use a pessimistic lock inside a transaction

This commit is contained in:
snipe
2026-05-26 14:24:02 +01:00
parent f92a9a6cc6
commit c25d56ea85
8 changed files with 774 additions and 85 deletions
@@ -132,91 +132,110 @@ class LicenseSeatsController extends Controller
$this->authorize('checkout', License::class);
$licenseSeat = LicenseSeat::with(['license', 'asset', 'user'])->find($seatId);
$errorResponse = null;
$updatedSeat = null;
if (! $licenseSeat) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found'));
}
// Fetch the seat with a pessimistic lock inside a transaction so concurrent requests
// on the same seat serialise rather than racing to overwrite each other's assignment.
DB::transaction(function () use ($request, $licenseId, $seatId, $validated, &$errorResponse, &$updatedSeat): void {
$licenseSeat = LicenseSeat::with(['license', 'asset', 'user'])
->lockForUpdate()
->find($seatId);
$license = $licenseSeat->license;
if (! $license || $license->id != intval($licenseId)) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
}
if (! $licenseSeat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found'));
$targetUser = null;
if (! is_null($request->input('assigned_to'))) {
// Resolve unscoped target so we can return a clean cross-company error instead of a hidden-not-found.
$targetUser = User::withoutGlobalScopes()->find($request->input('assigned_to'));
if (! $targetUser) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $targetUser->companies()->where('companies.id', $license->company_id)->exists())) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
}
$license = $licenseSeat->license;
if (! $license || $license->id != intval($licenseId)) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
$targetAsset = null;
if (! is_null($request->input('asset_id'))) {
// Resolve unscoped target so FMCS company mismatch can be enforced explicitly.
$targetAsset = Asset::withoutGlobalScopes()->find($request->input('asset_id'));
if (! $targetAsset) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetAsset->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
$targetUser = null;
if (! is_null($request->input('assigned_to'))) {
// Resolve unscoped target so we can return a clean cross-company error instead of a hidden-not-found.
$targetUser = User::withoutGlobalScopes()->find($request->input('assigned_to'));
if (! $targetUser) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && (! $targetUser->companies()->where('companies.id', $license->company_id)->exists())) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
return;
}
}
}
$oldUser = $licenseSeat->user;
$oldAsset = $licenseSeat->asset;
$targetAsset = null;
if (! is_null($request->input('asset_id'))) {
// Resolve unscoped target so FMCS company mismatch can be enforced explicitly.
$targetAsset = Asset::withoutGlobalScopes()->find($request->input('asset_id'));
// attempt to update the license seat
$licenseSeat->fill($validated);
if (! $targetAsset) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
// check if this update is a checkin operation
// 1. are relevant fields touched at all?
$assignmentTouched = $licenseSeat->isDirty('assigned_to') || $licenseSeat->isDirty('asset_id');
$anythingTouched = $licenseSeat->isDirty();
return;
}
if (! $anythingTouched) {
return response()->json(
Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success'))
);
}
if ($assignmentTouched && $licenseSeat->unreassignable_seat) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.unavailable')));
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetAsset->company_id)) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
// 2. are they cleared? if yes then this is a checkin operation
$is_checkin = ($assignmentTouched && $licenseSeat->assigned_to === null && $licenseSeat->asset_id === null);
$target = null;
// the logging functions expect only one "target". if both asset and user are present in the request,
// we simply let assets take precedence over users...
if ($licenseSeat->isDirty('assigned_to')) {
$target = $is_checkin ? $oldUser : $targetUser;
}
if ($licenseSeat->isDirty('asset_id')) {
$target = $is_checkin ? $oldAsset : $targetAsset;
}
if ($assignmentTouched && is_null($target)) {
// if both asset_id and assigned_to are null then we are "checking-in"
// a related model that does not exist (possible purged or bad data).
if (! is_null($request->input('asset_id')) || ! is_null($request->input('assigned_to'))) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
}
$oldUser = $licenseSeat->user;
$oldAsset = $licenseSeat->asset;
$licenseSeat->fill($validated);
$assignmentTouched = $licenseSeat->isDirty('assigned_to') || $licenseSeat->isDirty('asset_id');
$anythingTouched = $licenseSeat->isDirty();
if (! $anythingTouched) {
$updatedSeat = $licenseSeat;
return;
}
if ($assignmentTouched && $licenseSeat->unreassignable_seat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.unavailable')));
return;
}
// Are the assignment fields cleared? If yes, this is a checkin operation.
$is_checkin = ($assignmentTouched && $licenseSeat->assigned_to === null && $licenseSeat->asset_id === null);
// The logging functions expect only one "target"; assets take precedence over users.
$target = null;
if ($licenseSeat->isDirty('assigned_to')) {
$target = $is_checkin ? $oldUser : $targetUser;
}
if ($licenseSeat->isDirty('asset_id')) {
$target = $is_checkin ? $oldAsset : $targetAsset;
}
if ($assignmentTouched && is_null($target)) {
// Both fields are null but one was provided — the related model is purged or bad data.
if (! is_null($request->input('asset_id')) || ! is_null($request->input('assigned_to'))) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
return;
}
}
}
// Keep seat updates and checkout/checkin logging atomic to prevent partial state changes.
$updated = DB::transaction(function () use ($licenseSeat, $assignmentTouched, $is_checkin, $target, $request): bool {
if (! $licenseSeat->save()) {
return false;
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
return;
}
if ($assignmentTouched) {
@@ -225,25 +244,29 @@ class LicenseSeatsController extends Controller
$licenseSeat->unreassignable_seat = true;
if (! $licenseSeat->save()) {
return false;
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
return;
}
}
// todo: skip if target is null?
$licenseSeat->logCheckin($target, $licenseSeat->notes);
} else {
// in this case, relevant fields are touched but it's not a checkin operation. so it must be a checkout operation.
$licenseSeat->logCheckout($request->input('notes'), $target);
}
}
return true;
$updatedSeat = $licenseSeat;
});
if ($updated) {
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
if ($errorResponse) {
return $errorResponse;
}
return Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors());
if ($updatedSeat) {
return response()->json(Helper::formatStandardApiResponse('success', $updatedSeat, trans('admin/licenses/message.update.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, 'An unexpected error occurred'), 500);
}
}
@@ -2,15 +2,21 @@
namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedIn;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\LicenseSeatsTransformer;
use App\Http\Transformers\LicensesTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Asset;
use App\Models\Company;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -261,6 +267,167 @@ class LicensesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.assoc_users')));
}
/**
* Checkout a license seat to a user or asset.
*
* Accepts an optional `seat_id`; if omitted the next available free seat is used.
* `target_type` must be "user" or "asset". Supply `assigned_to` for users or
* `asset_id` for assets.
*
* This will eventually use the same form request the UI uses, but we need to update the field names first.
*
* @param int $licenseId
*/
public function checkout(Request $request, $licenseId): JsonResponse
{
$license = License::findOrFail($licenseId);
$this->authorize('checkout', $license);
$validated = $this->validate($request, [
'seat_id' => 'sometimes|integer|nullable',
'target_type' => 'required|in:user,asset',
'assigned_to' => 'required_if:target_type,user|integer|nullable',
'asset_id' => 'required_if:target_type,asset|integer|nullable',
'notes' => 'sometimes|string|nullable',
]);
if ($license->isInactive()) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.license_is_inactive')));
}
$errorResponse = null;
$updatedSeat = null;
$target = null;
DB::transaction(function () use ($license, $validated, &$errorResponse, &$updatedSeat, &$target): void {
$seatId = $validated['seat_id'] ?? null;
$licenseSeat = $seatId
? LicenseSeat::where('id', $seatId)->where('license_id', $license->id)->lockForUpdate()->first()
: $license->freeSeat(lock: true);
if (! $licenseSeat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.not_enough_seats')));
return;
}
if ($licenseSeat->unreassignable_seat) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.unavailable')));
return;
}
if ($validated['target_type'] === 'user') {
$target = User::withoutGlobalScopes()->whereNull('deleted_at')->find($validated['assigned_to'] ?? null);
if (! $target) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.user_does_not_exist')));
return;
}
if (Company::isFullMultipleCompanySupportEnabled() && ! $target->companies()->where('companies.id', $license->company_id)->exists()) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
return;
}
$licenseSeat->assigned_to = $target->id;
$licenseSeat->asset_id = null;
} else {
$target = Asset::withoutGlobalScopes()->whereNull('deleted_at')->find($validated['asset_id'] ?? null);
if (! $target) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.asset_does_not_exist')));
return;
}
if (Company::isFullMultipleCompanySupportEnabled() && $license->company_id && $license->company_id !== $target->company_id) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
return;
}
$licenseSeat->asset_id = $target->id;
$licenseSeat->assigned_to = null;
if ($target->checkedOutToUser()) {
$licenseSeat->assigned_to = $target->assigned_to;
}
}
$licenseSeat->notes = $validated['notes'] ?? null;
$licenseSeat->created_by = auth()->id();
if (! $licenseSeat->save()) {
$errorResponse = response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
return;
}
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), $validated['notes'] ?? null));
$updatedSeat = $licenseSeat->load('license', 'user', 'asset');
});
if ($errorResponse) {
return $errorResponse;
}
if ($updatedSeat) {
return response()->json(Helper::formatStandardApiResponse('success', (new LicenseSeatsTransformer)->transformLicenseSeat($updatedSeat), trans('admin/licenses/message.checkout.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, 'An unexpected error occurred'), 500);
}
/**
* Checkin a license seat.
*
* `seat_id` is required to identify which seat to check back in.
*
* @param int $licenseId
*/
public function checkin(Request $request, $licenseId): JsonResponse
{
$license = License::findOrFail($licenseId);
$this->authorize('checkin', $license);
$validated = $this->validate($request, [
'seat_id' => 'required|integer',
'notes' => 'sometimes|string|nullable',
]);
$licenseSeat = LicenseSeat::where('id', $validated['seat_id'])
->where('license_id', $license->id)
->first();
if (! $licenseSeat) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.not_found')));
}
if (is_null($licenseSeat->assigned_to) && is_null($licenseSeat->asset_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkin.error')));
}
$target = $licenseSeat->user ?? $licenseSeat->asset;
$licenseSeat->assigned_to = null;
$licenseSeat->asset_id = null;
$licenseSeat->notes = $validated['notes'] ?? null;
if (! $license->reassignable) {
$licenseSeat->unreassignable_seat = true;
}
if (! $licenseSeat->save()) {
return response()->json(Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors()));
}
event(new CheckoutableCheckedIn($licenseSeat, $target, auth()->user(), $licenseSeat->notes));
return response()->json(Helper::formatStandardApiResponse('success', (new LicenseSeatsTransformer)->transformLicenseSeat($licenseSeat->load('license', 'user', 'asset')), trans('admin/licenses/message.checkin.success')));
}
/**
* Gets a paginated collection for the select2 menus
*
@@ -15,6 +15,7 @@ use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class LicenseCheckoutController extends Controller
@@ -94,23 +95,31 @@ class LicenseCheckoutController extends Controller
return redirect()->route('licenses.index')->with('error', trans('admin/licenses/message.checkout.license_is_inactive'));
}
$licenseSeat = $this->findLicenseSeatToCheckout($license, $seatId);
$licenseSeat->created_by = auth()->id();
$licenseSeat->notes = $request->input('notes');
$licenseSeat = null;
$checkoutTarget = null;
DB::transaction(function () use ($request, $license, $seatId, &$licenseSeat, &$checkoutTarget): void {
$licenseSeat = $this->findLicenseSeatToCheckout($license, $seatId, lock: true);
$licenseSeat->created_by = auth()->id();
$licenseSeat->notes = $request->input('notes');
if ($request->filled('asset_id')) {
$checkoutTarget = $this->checkoutToAsset($licenseSeat);
} elseif ($request->filled('assigned_to')) {
$checkoutTarget = $this->checkoutToUser($licenseSeat);
}
});
if ($request->filled('asset_id')) {
session()->put(['checkout_to_type' => 'asset']);
$checkoutTarget = $this->checkoutToAsset($licenseSeat);
$request->request->add(['assigned_asset' => $checkoutTarget->id]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => 'asset',
'sign_in_place' => $request->boolean('sign_in_place'),
]);
} elseif ($request->filled('assigned_to')) {
session()->put(['checkout_to_type' => 'user']);
$checkoutTarget = $this->checkoutToUser($licenseSeat);
$request->request->add(['assigned_user' => $checkoutTarget->id]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
@@ -156,9 +165,11 @@ class LicenseCheckoutController extends Controller
return redirect()->route('licenses.index')->with('error', trans('Something went wrong handling this checkout.'));
}
protected function findLicenseSeatToCheckout($license, $seatId)
protected function findLicenseSeatToCheckout($license, $seatId, bool $lock = false)
{
$licenseSeat = LicenseSeat::find($seatId) ?? $license->freeSeat();
$licenseSeat = $seatId
? LicenseSeat::where('id', $seatId)->when($lock, fn ($q) => $q->lockForUpdate())->first()
: $license->freeSeat(lock: $lock);
if (! $licenseSeat) {
if ($seatId) {
+2 -1
View File
@@ -803,7 +803,7 @@ class License extends Depreciable
*
* @return mixed
*/
public function freeSeat()
public function freeSeat(bool $lock = false)
{
return $this->licenseseats()
->whereNull('deleted_at')
@@ -813,6 +813,7 @@ class License extends Depreciable
->whereNull('asset_id');
})
->orderBy('id', 'asc')
->when($lock, fn ($q) => $q->lockForUpdate())
->first();
}
+14
View File
@@ -726,6 +726,20 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu
]
)->name('api.licenses.history')->withTrashed();
Route::post('{license_id}/checkout',
[
Api\LicensesController::class,
'checkout',
]
)->name('api.licenses.checkout');
Route::post('{license_id}/checkin',
[
Api\LicensesController::class,
'checkin',
]
)->name('api.licenses.checkin');
});
Route::resource('licenses',
+1 -1
View File
@@ -19,7 +19,7 @@ Route::group(['prefix' => 'licenses', 'middleware' => ['auth']], function () {
Route::post(
'{licenseId}/checkout/{seatId?}',
[Licenses\LicenseCheckoutController::class, 'store']
); // name() would duplicate here, so we skip it.
)->name('licenses.checkout.save');
Route::get('{licenseSeat}/checkin/{backto?}', [Licenses\LicenseCheckinController::class, 'create'])
->name('licenses.checkin')
@@ -0,0 +1,318 @@
<?php
namespace Tests\Feature\Licenses\Api;
use App\Events\CheckoutableCheckedIn;
use App\Events\CheckoutableCheckedOut;
use App\Models\Asset;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class LicenseCheckoutCheckinTest extends TestCase
{
// ---------------------------------------------------------------------------
// Checkout
// ---------------------------------------------------------------------------
#[Test]
public function checkout_requires_checkout_permission(): void
{
$license = License::factory()->create(['seats' => 1]);
$this->actingAsForApi(User::factory()->create())
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'user',
'assigned_to' => User::factory()->create()->id,
])
->assertForbidden();
}
#[Test]
public function checkout_to_user_assigns_free_seat(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 1]);
$target = User::factory()->create();
$this->actingAsForApi(User::factory()->checkoutLicenses()->create())
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'user',
'assigned_to' => $target->id,
])
->assertOk()
->assertJson(['status' => 'success']);
$seat = $license->licenseseats()->first();
$this->assertEquals($target->id, $seat->assigned_to);
Event::assertDispatched(CheckoutableCheckedOut::class);
}
#[Test]
public function checkout_to_asset_assigns_free_seat(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 1]);
$asset = Asset::factory()->create();
$this->actingAsForApi(User::factory()->checkoutLicenses()->create())
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'asset',
'asset_id' => $asset->id,
])
->assertOk()
->assertJson(['status' => 'success']);
$seat = $license->licenseseats()->first();
$this->assertEquals($asset->id, $seat->asset_id);
Event::assertDispatched(CheckoutableCheckedOut::class);
}
#[Test]
public function checkout_to_specific_seat_by_id(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 3]);
$seats = $license->licenseseats()->orderBy('id')->get();
$target = User::factory()->create();
$this->actingAsForApi(User::factory()->checkoutLicenses()->create())
->postJson(route('api.licenses.checkout', $license->id), [
'seat_id' => $seats[1]->id,
'target_type' => 'user',
'assigned_to' => $target->id,
])
->assertOk()
->assertJson(['status' => 'success']);
$this->assertEquals($target->id, $seats[1]->fresh()->assigned_to);
$this->assertNull($seats[0]->fresh()->assigned_to);
$this->assertNull($seats[2]->fresh()->assigned_to);
Event::assertDispatched(CheckoutableCheckedOut::class);
}
#[Test]
public function checkout_fails_when_no_seats_available(): void
{
$license = License::factory()->create(['seats' => 1]);
LicenseSeat::where('license_id', $license->id)->update(['assigned_to' => User::factory()->create()->id]);
$this->actingAsForApi(User::factory()->checkoutLicenses()->create())
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'user',
'assigned_to' => User::factory()->create()->id,
])
->assertJson(['status' => 'error']);
}
#[Test]
public function checkout_returns_error_for_nonexistent_user(): void
{
$license = License::factory()->create(['seats' => 1]);
$this->actingAsForApi(User::factory()->checkoutLicenses()->create())
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'user',
'assigned_to' => 99999,
])
->assertJson(['status' => 'error']);
}
#[Test]
public function checkout_returns_error_for_nonexistent_asset(): void
{
$license = License::factory()->create(['seats' => 1]);
$this->actingAsForApi(User::factory()->checkoutLicenses()->create())
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'asset',
'asset_id' => 99999,
])
->assertJson(['status' => 'error']);
}
#[Test]
public function sequential_checkouts_each_receive_a_distinct_seat(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 2]);
$actor = User::factory()->checkoutLicenses()->create();
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$this->actingAsForApi($actor)
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'user',
'assigned_to' => $user1->id,
])
->assertJson(['status' => 'success']);
$this->actingAsForApi($actor)
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'user',
'assigned_to' => $user2->id,
])
->assertJson(['status' => 'success']);
$assignedTo = $license->licenseseats()->pluck('assigned_to');
$this->assertCount(2, $assignedTo->filter());
$this->assertEquals(2, $assignedTo->unique()->count());
Event::assertDispatched(CheckoutableCheckedOut::class, 2);
}
// ---------------------------------------------------------------------------
// Checkin
// ---------------------------------------------------------------------------
#[Test]
public function checkin_requires_checkin_permission(): void
{
$license = License::factory()->create(['seats' => 1]);
$seat = $license->licenseseats()->first();
$seat->update(['assigned_to' => User::factory()->create()->id]);
$this->actingAsForApi(User::factory()->create())
->postJson(route('api.licenses.checkin', $license->id), [
'seat_id' => $seat->id,
])
->assertForbidden();
}
#[Test]
public function checkin_clears_assigned_user(): void
{
Event::fake([CheckoutableCheckedIn::class]);
$license = License::factory()->create(['seats' => 1, 'reassignable' => true]);
$user = User::factory()->create();
$seat = $license->licenseseats()->first();
$seat->update(['assigned_to' => $user->id]);
$this->actingAsForApi(User::factory()->checkinLicenses()->create())
->postJson(route('api.licenses.checkin', $license->id), [
'seat_id' => $seat->id,
])
->assertOk()
->assertJson(['status' => 'success']);
$this->assertNull($seat->fresh()->assigned_to);
$this->assertFalse((bool) $seat->fresh()->unreassignable_seat);
Event::assertDispatched(CheckoutableCheckedIn::class);
}
#[Test]
public function checkin_clears_assigned_asset(): void
{
Event::fake([CheckoutableCheckedIn::class]);
$license = License::factory()->create(['seats' => 1, 'reassignable' => true]);
$asset = Asset::factory()->create();
$seat = $license->licenseseats()->first();
$seat->update(['asset_id' => $asset->id]);
$this->actingAsForApi(User::factory()->checkinLicenses()->create())
->postJson(route('api.licenses.checkin', $license->id), [
'seat_id' => $seat->id,
])
->assertOk()
->assertJson(['status' => 'success']);
$this->assertNull($seat->fresh()->asset_id);
Event::assertDispatched(CheckoutableCheckedIn::class);
}
#[Test]
public function checkin_marks_seat_unreassignable_when_license_is_not_reassignable(): void
{
Event::fake([CheckoutableCheckedIn::class]);
$license = License::factory()->create(['seats' => 1, 'reassignable' => false]);
$user = User::factory()->create();
$seat = $license->licenseseats()->first();
$seat->update(['assigned_to' => $user->id]);
$this->actingAsForApi(User::factory()->checkinLicenses()->create())
->postJson(route('api.licenses.checkin', $license->id), [
'seat_id' => $seat->id,
])
->assertOk()
->assertJson(['status' => 'success']);
$this->assertNull($seat->fresh()->assigned_to);
$this->assertTrue((bool) $seat->fresh()->unreassignable_seat);
Event::assertDispatched(CheckoutableCheckedIn::class);
}
#[Test]
public function checkin_returns_error_for_unassigned_seat(): void
{
$license = License::factory()->create(['seats' => 1]);
$seat = $license->licenseseats()->first();
$this->actingAsForApi(User::factory()->checkinLicenses()->create())
->postJson(route('api.licenses.checkin', $license->id), [
'seat_id' => $seat->id,
])
->assertJson(['status' => 'error']);
}
#[Test]
public function checkin_returns_error_for_seat_not_belonging_to_license(): void
{
$license1 = License::factory()->create(['seats' => 1]);
$license2 = License::factory()->create(['seats' => 1]);
$seat2 = $license2->licenseseats()->first();
$seat2->update(['assigned_to' => User::factory()->create()->id]);
$this->actingAsForApi(User::factory()->checkinLicenses()->create())
->postJson(route('api.licenses.checkin', $license1->id), [
'seat_id' => $seat2->id,
])
->assertJson(['status' => 'error']);
}
#[Test]
public function checkout_then_checkin_frees_the_seat(): void
{
Event::fake([CheckoutableCheckedOut::class, CheckoutableCheckedIn::class]);
$license = License::factory()->create(['seats' => 1, 'reassignable' => true]);
$user = User::factory()->create();
$actor = User::factory()->checkoutLicenses()->checkinLicenses()->create();
$this->actingAsForApi($actor)
->postJson(route('api.licenses.checkout', $license->id), [
'target_type' => 'user',
'assigned_to' => $user->id,
])
->assertJson(['status' => 'success']);
$seat = $license->licenseseats()->first();
$this->assertEquals($user->id, $seat->fresh()->assigned_to);
$this->actingAsForApi($actor)
->postJson(route('api.licenses.checkin', $license->id), [
'seat_id' => $seat->id,
])
->assertJson(['status' => 'success']);
$this->assertNull($seat->fresh()->assigned_to);
Event::assertDispatched(CheckoutableCheckedOut::class);
Event::assertDispatched(CheckoutableCheckedIn::class);
}
}
@@ -0,0 +1,155 @@
<?php
namespace Tests\Feature\Licenses\Ui;
use App\Events\CheckoutableCheckedOut;
use App\Models\Asset;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class LicenseCheckoutTest extends TestCase
{
#[Test]
public function requires_checkout_permission(): void
{
$license = License::factory()->create(['seats' => 1]);
$this->actingAs(User::factory()->create())
->post(route('licenses.checkout.save', $license->id), [
'assigned_to' => User::factory()->create()->id,
])
->assertForbidden();
}
#[Test]
public function checkout_to_user_assigns_free_seat(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 1]);
$target = User::factory()->create();
$seat = $license->licenseseats()->first();
$this->actingAs(User::factory()->checkoutLicenses()->create())
->post(route('licenses.checkout.save', $license->id), [
'assigned_to' => $target->id,
])
->assertRedirect()
->assertSessionHas('success');
$this->assertEquals($target->id, $seat->fresh()->assigned_to);
Event::assertDispatched(CheckoutableCheckedOut::class);
}
#[Test]
public function checkout_to_asset_assigns_free_seat(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 1]);
$asset = Asset::factory()->create();
$seat = $license->licenseseats()->first();
$this->actingAs(User::factory()->checkoutLicenses()->create())
->post(route('licenses.checkout.save', $license->id), [
'asset_id' => $asset->id,
])
->assertRedirect()
->assertSessionHas('success');
$this->assertEquals($asset->id, $seat->fresh()->asset_id);
Event::assertDispatched(CheckoutableCheckedOut::class);
}
#[Test]
public function checkout_of_specific_seat_by_id(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 3]);
$seats = $license->licenseseats()->orderBy('id')->get();
$target = User::factory()->create();
$this->actingAs(User::factory()->checkoutLicenses()->create())
->post(route('licenses.checkout.save', ['licenseId' => $license->id, 'seatId' => $seats[1]->id]), [
'assigned_to' => $target->id,
])
->assertRedirect()
->assertSessionHas('success');
$this->assertEquals($target->id, $seats[1]->fresh()->assigned_to);
$this->assertNull($seats[0]->fresh()->assigned_to);
$this->assertNull($seats[2]->fresh()->assigned_to);
}
#[Test]
public function cannot_checkout_when_no_seats_available(): void
{
$license = License::factory()->create(['seats' => 1]);
LicenseSeat::where('license_id', $license->id)->update(['assigned_to' => User::factory()->create()->id]);
$this->actingAs(User::factory()->checkoutLicenses()->create())
->post(route('licenses.checkout.save', $license->id), [
'assigned_to' => User::factory()->create()->id,
])
->assertRedirect()
->assertSessionHas('error');
}
#[Test]
public function sequential_checkouts_each_receive_a_distinct_seat(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 2]);
$actor = User::factory()->checkoutLicenses()->create();
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$this->actingAs($actor)
->post(route('licenses.checkout.save', $license->id), ['assigned_to' => $user1->id])
->assertSessionHas('success');
$this->actingAs($actor)
->post(route('licenses.checkout.save', $license->id), ['assigned_to' => $user2->id])
->assertSessionHas('success');
$assignedTo = $license->licenseseats()->pluck('assigned_to');
$this->assertCount(2, $assignedTo->filter());
$this->assertContains($user1->id, $assignedTo);
$this->assertContains($user2->id, $assignedTo);
$this->assertEquals(2, $assignedTo->unique()->count(), 'Both users should hold different seats');
Event::assertDispatched(CheckoutableCheckedOut::class, 2);
}
#[Test]
public function third_checkout_fails_when_only_two_seats_exist(): void
{
Event::fake([CheckoutableCheckedOut::class]);
$license = License::factory()->create(['seats' => 2]);
$actor = User::factory()->checkoutLicenses()->create();
foreach ([User::factory()->create(), User::factory()->create()] as $user) {
$this->actingAs($actor)
->post(route('licenses.checkout.save', $license->id), ['assigned_to' => $user->id])
->assertSessionHas('success');
}
$this->actingAs($actor)
->post(route('licenses.checkout.save', $license->id), [
'assigned_to' => User::factory()->create()->id,
])
->assertRedirect()
->assertSessionHas('error');
$this->assertEquals(0, $license->fresh()->freeSeats()->count());
Event::assertDispatched(CheckoutableCheckedOut::class, 2);
}
}