3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00
Files
snipe-it/tests/Feature/Checkouts/Api/ConsumableCheckoutTest.php
2026-07-25 13:46:19 +01:00

270 lines
10 KiB
PHP

<?php
namespace Tests\Feature\Checkouts\Api;
use App\Mail\CheckoutConsumableMail;
use App\Models\Actionlog;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class ConsumableCheckoutTest extends TestCase
{
public function test_checking_out_consumable_requires_correct_permission()
{
$this->actingAsForApi(User::factory()->create())
->postJson(route('api.consumables.checkout', Consumable::factory()->create()))
->assertForbidden();
}
public function test_validation_when_checking_out_consumable()
{
$this->actingAsForApi(User::factory()->checkoutConsumables()->create())
->postJson(route('api.consumables.checkout', Consumable::factory()->create()), [
// missing assigned_to
])
->assertStatusMessageIs('error');
}
public function test_consumable_must_be_available_when_checking_out()
{
$this->actingAsForApi(User::factory()->checkoutConsumables()->create())
->postJson(route('api.consumables.checkout', Consumable::factory()->withoutItemsRemaining()->create()), [
'assigned_to' => User::factory()->create()->id,
])
->assertStatusMessageIs('error');
}
public function test_consumable_can_be_checked_out()
{
$consumable = Consumable::factory()->create();
$user = User::factory()->create();
$this->actingAsForApi(User::factory()->checkoutConsumables()->create())
->postJson(route('api.consumables.checkout', $consumable), [
'assigned_to' => $user->id,
]);
$this->assertTrue($user->consumables->contains($consumable));
$this->assertHasTheseActionLogs($consumable, ['create', 'checkout']);
}
public function test_consumable_can_be_checked_out_with_quantity()
{
$consumable = Consumable::factory()->create();
$user = User::factory()->create();
$this->actingAsForApi(User::factory()->checkoutConsumables()->create())
->postJson(route('api.consumables.checkout', $consumable), [
'assigned_to' => $user->id,
'checkout_qty' => 2,
]);
$this->assertDatabaseHas('action_logs', [
'item_type' => Consumable::class,
'item_id' => $consumable->id,
'target_type' => User::class,
'target_id' => $user->id,
'action_type' => 'checkout',
'quantity' => 2,
]);
}
public function test_user_sent_notification_upon_checkout()
{
Mail::fake();
$consumable = Consumable::factory()->requiringAcceptance()->create();
$user = User::factory()->create();
$this->actingAsForApi(User::factory()->checkoutConsumables()->create())
->postJson(route('api.consumables.checkout', $consumable), [
'assigned_to' => $user->id,
]);
Mail::assertSent(CheckoutConsumableMail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
}
public function test_pivot_row_created_by_is_the_actor_not_the_target()
{
// Regression: previously the pivot's created_by was set to $user->id
// (the checkout target), so audit surfaces that read consumables_users
// (e.g. "who checked this consumable out to me") would show the target
// as their own creator. The action_logs stream separately recorded the
// correct actor, which is why the pivot bug survived.
$consumable = Consumable::factory()->create();
$actor = User::factory()->checkoutConsumables()->create();
$target = User::factory()->create();
$this->actingAsForApi($actor)
->postJson(route('api.consumables.checkout', $consumable), [
'assigned_to' => $target->id,
'note' => 'created_by attribution regression',
]);
$this->assertDatabaseHas('consumables_users', [
'consumable_id' => $consumable->id,
'assigned_to' => $target->id,
'created_by' => $actor->id,
]);
$this->assertDatabaseMissing('consumables_users', [
'consumable_id' => $consumable->id,
'assigned_to' => $target->id,
'created_by' => $target->id,
]);
}
public function test_action_log_created_upon_checkout()
{
$consumable = Consumable::factory()->create();
$actor = User::factory()->checkoutConsumables()->create();
$user = User::factory()->create();
$this->actingAsForApi($actor)
->postJson(route('api.consumables.checkout', $consumable), [
'assigned_to' => $user->id,
'note' => 'oh hi there',
]);
$this->assertEquals(
1,
Actionlog::where([
'action_type' => 'checkout',
'target_id' => $user->id,
'target_type' => User::class,
'item_id' => $consumable->id,
'item_type' => Consumable::class,
'created_by' => $actor->id,
'note' => 'oh hi there',
])->count(),
'Log entry either does not exist or there are more than expected'
);
}
public function test_superuser_cannot_checkout_consumable_to_a_user_in_another_company_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$superuser = User::factory()->superuser()->withoutCompany()->create();
$consumableInCompanyA = Consumable::factory()->for($companyA)->create(['qty' => 1]);
$userInCompanyB = User::factory()->forCompany($companyB)->create();
$this->actingAsForApi($superuser)
->postJson(route('api.consumables.checkout', $consumableInCompanyA), [
'assigned_to' => $userInCompanyB->id,
'checkout_qty' => 1,
])
->assertOk()
->assertStatusMessageIs('error')
->assertMessagesAre(trans('general.error_user_company'));
$this->assertDatabaseMissing('consumables_users', [
'consumable_id' => $consumableInCompanyA->id,
'assigned_to' => $userInCompanyB->id,
]);
$this->assertDatabaseMissing('action_logs', [
'created_by' => $superuser->id,
'action_type' => 'checkout',
'target_type' => User::class,
'target_id' => $userInCompanyB->id,
'item_type' => Consumable::class,
'item_id' => $consumableInCompanyA->id,
]);
$this->assertEquals(1, $consumableInCompanyA->fresh()->numRemaining());
}
public function test_user_in_same_company_can_checkout_consumable_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
$company = Company::factory()->create();
$consumable = Consumable::factory()->for($company)->create(['qty' => 5]);
$target = $company->users()->save(User::factory()->make());
$actor = User::factory()->superuser()->create();
$this->actingAsForApi($actor)
->postJson(route('api.consumables.checkout', $consumable), [
'assigned_to' => $target->id,
])
->assertOk()
->assertStatusMessageIs('success');
}
public function test_user_in_multiple_companies_can_checkout_consumable_from_any_of_their_companies_when_full_company_support_is_enabled()
{
$this->settings->enableMultipleFullCompanySupport();
[$companyA, $companyB] = Company::factory()->count(2)->create();
$target = User::factory()->create();
$target->companies()->sync([$companyA->id, $companyB->id]);
$consumableInA = Consumable::factory()->for($companyA)->create(['qty' => 5]);
$consumableInB = Consumable::factory()->for($companyB)->create(['qty' => 5]);
$actor = User::factory()->superuser()->create();
$this->actingAsForApi($actor)
->postJson(route('api.consumables.checkout', $consumableInA), [
'assigned_to' => $target->id,
])
->assertOk()
->assertStatusMessageIs('success');
$this->actingAsForApi($actor)
->postJson(route('api.consumables.checkout', $consumableInB), [
'assigned_to' => $target->id,
])
->assertOk()
->assertStatusMessageIs('success');
}
/**
* Security regression pin: the checkout endpoint used to read
* numRemaining() outside any transaction/lock. Two racing requests
* for a qty=1 consumable both saw "1 available", both attached
* pivot rows, and the register landed at -1. The fix wraps the
* pivot writes in a DB::transaction that begins with a
* lockForUpdate re-fetch + re-check of numRemaining. This test
* simulates the "someone else already grabbed the last one"
* moment by pre-attaching pivot rows to drain the consumable to
* zero before the checkout request runs, and asserts the endpoint
* refuses instead of over-allocating.
*/
public function test_checkout_refuses_when_inventory_is_already_exhausted(): void
{
$target = User::factory()->create();
$consumable = Consumable::factory()->create(['qty' => 1]);
// Drain the consumable via a direct pivot insert. Same state a
// concurrent request would have left mid-transaction.
$consumable->users()->attach($consumable->id, [
'consumable_id' => $consumable->id,
'assigned_to' => $target->id,
'created_by' => User::factory()->superuser()->create()->id,
]);
$this->assertSame(0, $consumable->fresh()->numRemaining());
$this->actingAsForApi(User::factory()->superuser()->create())
->postJson(route('api.consumables.checkout', $consumable), [
'assigned_to' => $target->id,
'checkout_qty' => 1,
])
->assertOk()
->assertStatusMessageIs('error');
// The pre-drained row is the only pivot; no second row got added.
$this->assertSame(1, $consumable->users()->count(), 'A second pivot row would mean the register went negative');
$this->assertSame(0, $consumable->fresh()->numRemaining());
}
}