383 lines
16 KiB
PHP
383 lines
16 KiB
PHP
<?php
|
|
|
|
use App\Domain\Identity\Enums\RoleName;
|
|
use App\Domain\Identity\Models\Role;
|
|
use App\Domain\Inventory\Models\SnipeAsset;
|
|
use App\Domain\Operations\Enums\HandoverType;
|
|
use App\Domain\Operations\Models\HandoverRecord;
|
|
use App\Domain\Reservations\Enums\ReservationItemStatus;
|
|
use App\Domain\Reservations\Enums\ReservationStatus;
|
|
use App\Domain\Reservations\Models\Reservation;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\Client\Request;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Inertia\Testing\AssertableInertia as Assert;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
config()->set('services.snipe_it', [
|
|
'url' => 'https://inventory.example.test',
|
|
'read_token' => 'read-token-for-tests',
|
|
'operational_token' => 'operational-token-for-tests',
|
|
'timeout' => 2,
|
|
'page_size' => 100,
|
|
'retries' => 1,
|
|
'retry_delay_ms' => 0,
|
|
]);
|
|
});
|
|
|
|
function inventoryOperator(): User
|
|
{
|
|
$operator = User::factory()->create();
|
|
$role = Role::firstOrCreate(['name' => RoleName::InventoryOperator->value]);
|
|
$operator->roles()->attach($role);
|
|
|
|
return $operator;
|
|
}
|
|
|
|
/** @param list<SnipeAsset> $assets */
|
|
function approvedOperationReservation(User $requester, array $assets, string $reference): Reservation
|
|
{
|
|
$reservation = Reservation::create([
|
|
'reference' => $reference,
|
|
'requester_id' => $requester->id,
|
|
'purpose' => 'Aula prática com equipamento audiovisual.',
|
|
'usage_location' => 'Sala 12',
|
|
'starts_at' => now()->subHour(),
|
|
'ends_at' => now()->addHours(3),
|
|
'status' => ReservationStatus::Approved,
|
|
'approved_at' => now()->subDay(),
|
|
]);
|
|
|
|
foreach ($assets as $asset) {
|
|
$reservation->items()->create([
|
|
'snipe_asset_id' => $asset->id,
|
|
'starts_at' => $reservation->starts_at,
|
|
'ends_at' => $reservation->ends_at,
|
|
'buffer_starts_at' => $reservation->starts_at,
|
|
'buffer_ends_at' => $reservation->ends_at,
|
|
'status' => ReservationItemStatus::Reserved,
|
|
]);
|
|
}
|
|
|
|
return $reservation;
|
|
}
|
|
|
|
/** @param array<int, int> $assignedUsers */
|
|
function fakeSuccessfulSnipeOperations(array $assignedUsers = []): void
|
|
{
|
|
Http::fake(function (Request $request) use ($assignedUsers) {
|
|
if ($request->method() === 'GET' && preg_match('#/hardware/(\d+)$#', $request->url(), $matches) === 1) {
|
|
$hardwareId = (int) $matches[1];
|
|
|
|
return Http::response(operationSnipeRow($hardwareId, $assignedUsers[$hardwareId] ?? null));
|
|
}
|
|
|
|
return Http::response(['status' => 'success', 'payload' => ['id' => 1]]);
|
|
});
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
function operationSnipeRow(int $hardwareId, ?int $assignedUserId = null): array
|
|
{
|
|
return [
|
|
'id' => $hardwareId,
|
|
'asset_tag' => "EQ-{$hardwareId}",
|
|
'name' => "Equipamento {$hardwareId}",
|
|
'assigned_to' => $assignedUserId === null ? null : [
|
|
'id' => $assignedUserId,
|
|
'type' => 'user',
|
|
],
|
|
];
|
|
}
|
|
|
|
it('restringe e apresenta a fila diária aos operadores', function () {
|
|
$reservation = approvedOperationReservation(
|
|
User::factory()->create(),
|
|
[SnipeAsset::factory()->create()],
|
|
'REQ-OPS-QUEUE',
|
|
);
|
|
|
|
$this->actingAs(User::factory()->create())->get('/operacoes')->assertForbidden();
|
|
|
|
$this->actingAs(inventoryOperator())
|
|
->get('/operacoes')
|
|
->assertOk()
|
|
->assertInertia(fn (Assert $page) => $page
|
|
->component('operations/index')
|
|
->where('operationallyConfigured', true)
|
|
->has('reservations', 1)
|
|
->where('reservations.0.reference', $reservation->reference));
|
|
});
|
|
|
|
it('prepara e entrega todos os equipamentos com registo auditável', function () {
|
|
$operator = inventoryOperator();
|
|
$requester = User::factory()->create();
|
|
$asset = SnipeAsset::factory()->create(['snipe_asset_id' => 501, 'status_id' => 2]);
|
|
$reservation = approvedOperationReservation($requester, [$asset], 'REQ-OPS-DELIVER');
|
|
|
|
$this->actingAs($operator)
|
|
->post("/operacoes/{$reservation->uuid}/preparar")
|
|
->assertRedirect()
|
|
->assertSessionHas('success');
|
|
|
|
expect($reservation->fresh()->status)->toBe(ReservationStatus::Ready)
|
|
->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Ready);
|
|
|
|
fakeSuccessfulSnipeOperations();
|
|
|
|
$this->actingAs($operator)
|
|
->post("/operacoes/{$reservation->uuid}/entregar", [
|
|
'snipe_user_id' => 77,
|
|
'condition' => 'Sem danos visíveis',
|
|
'notes' => 'Carregador incluído.',
|
|
])
|
|
->assertRedirect()
|
|
->assertSessionHas('success');
|
|
|
|
expect($reservation->fresh()->status)->toBe(ReservationStatus::Delivered)
|
|
->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Delivered)
|
|
->and($requester->fresh()->snipe_user_id)->toBe(77);
|
|
$this->assertDatabaseHas('handover_records', [
|
|
'reservation_item_id' => $reservation->items()->firstOrFail()->id,
|
|
'type' => 'checkout',
|
|
'operator_id' => $operator->id,
|
|
'recipient_id' => $requester->id,
|
|
]);
|
|
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.delivered']);
|
|
Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/hardware/501/checkout'));
|
|
|
|
$this->actingAs($operator)
|
|
->post("/operacoes/{$reservation->uuid}/entregar", [
|
|
'snipe_user_id' => 77,
|
|
'condition' => 'Sem danos visíveis',
|
|
])
|
|
->assertSessionHasErrors('operation');
|
|
Http::assertSentCount(2);
|
|
});
|
|
|
|
it('não toca num equipamento que já esteja em checkout no Snipe-IT', function () {
|
|
$operator = inventoryOperator();
|
|
$asset = SnipeAsset::factory()->create(['snipe_asset_id' => 551, 'status_id' => 2]);
|
|
$reservation = approvedOperationReservation(User::factory()->create(), [$asset], 'REQ-OPS-REMOTE-ASSIGNED');
|
|
$reservation->update(['status' => ReservationStatus::Ready]);
|
|
$reservation->items()->update(['status' => ReservationItemStatus::Ready]);
|
|
fakeSuccessfulSnipeOperations([551 => 999]);
|
|
|
|
$this->actingAs($operator)
|
|
->post("/operacoes/{$reservation->uuid}/entregar", [
|
|
'snipe_user_id' => 77,
|
|
'condition' => 'Sem danos visíveis',
|
|
])
|
|
->assertSessionHasErrors('operation');
|
|
|
|
expect($reservation->fresh()->status)->toBe(ReservationStatus::Ready)
|
|
->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Ready);
|
|
Http::assertSentCount(1);
|
|
Http::assertSent(fn (Request $request): bool => $request->method() === 'GET');
|
|
$this->assertDatabaseCount('handover_records', 0);
|
|
});
|
|
|
|
it('regista a devolução no Snipe-IT e conclui o processo', function () {
|
|
$operator = inventoryOperator();
|
|
$requester = User::factory()->create(['snipe_user_id' => 77]);
|
|
$asset = SnipeAsset::factory()->create([
|
|
'snipe_asset_id' => 601,
|
|
'status_id' => 2,
|
|
'location_id' => 9,
|
|
]);
|
|
$reservation = approvedOperationReservation($requester, [$asset], 'REQ-OPS-RETURN');
|
|
$reservation->update(['status' => ReservationStatus::Delivered]);
|
|
$reservation->items()->update([
|
|
'status' => ReservationItemStatus::Delivered,
|
|
'delivered_at' => now()->subHours(2),
|
|
]);
|
|
|
|
$item = $reservation->items()->firstOrFail();
|
|
HandoverRecord::create([
|
|
'reservation_item_id' => $item->id,
|
|
'type' => HandoverType::Checkout,
|
|
'operator_id' => $operator->id,
|
|
'recipient_id' => $requester->id,
|
|
'condition' => 'Bom estado',
|
|
'snipe_response' => ['status' => 'success'],
|
|
]);
|
|
fakeSuccessfulSnipeOperations([601 => 77]);
|
|
|
|
$this->actingAs($operator)
|
|
->post("/operacoes/{$reservation->uuid}/devolver", [
|
|
'condition' => 'Bom estado',
|
|
'notes' => 'Conferido pelo operador.',
|
|
])
|
|
->assertRedirect()
|
|
->assertSessionHas('success');
|
|
|
|
expect($reservation->fresh()->status)->toBe(ReservationStatus::Returned)
|
|
->and($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Returned);
|
|
$this->assertDatabaseHas('handover_records', ['type' => 'checkin', 'operator_id' => $operator->id]);
|
|
Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/hardware/601/checkin')
|
|
&& $request['location_id'] === 9);
|
|
|
|
$this->actingAs($operator)
|
|
->post("/operacoes/{$reservation->uuid}/concluir")
|
|
->assertRedirect();
|
|
|
|
expect($reservation->fresh()->status)->toBe(ReservationStatus::Completed);
|
|
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.completed']);
|
|
});
|
|
|
|
it('bloqueia check-in sem checkout previamente confirmado pelo portal', function () {
|
|
$operator = inventoryOperator();
|
|
$requester = User::factory()->create(['snipe_user_id' => 77]);
|
|
$asset = SnipeAsset::factory()->create(['snipe_asset_id' => 651, 'status_id' => 2]);
|
|
$reservation = approvedOperationReservation($requester, [$asset], 'REQ-OPS-UNTRACKED-CHECKIN');
|
|
$reservation->update(['status' => ReservationStatus::Delivered]);
|
|
$reservation->items()->update(['status' => ReservationItemStatus::Delivered]);
|
|
Http::fake();
|
|
|
|
$this->actingAs($operator)
|
|
->post("/operacoes/{$reservation->uuid}/devolver", ['condition' => 'Bom estado'])
|
|
->assertSessionHasErrors('operation');
|
|
|
|
Http::assertNothingSent();
|
|
expect($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Delivered);
|
|
});
|
|
|
|
it('bloqueia check-in quando o checkout do portal pertence a outro destinatário', function () {
|
|
$operator = inventoryOperator();
|
|
$requester = User::factory()->create(['snipe_user_id' => 77]);
|
|
$otherRecipient = User::factory()->create(['snipe_user_id' => 88]);
|
|
$asset = SnipeAsset::factory()->create(['snipe_asset_id' => 652, 'status_id' => 2]);
|
|
$reservation = approvedOperationReservation($requester, [$asset], 'REQ-OPS-WRONG-RECIPIENT');
|
|
$reservation->update(['status' => ReservationStatus::Delivered]);
|
|
$reservation->items()->update(['status' => ReservationItemStatus::Delivered]);
|
|
HandoverRecord::create([
|
|
'reservation_item_id' => $reservation->items()->firstOrFail()->id,
|
|
'type' => HandoverType::Checkout,
|
|
'operator_id' => $operator->id,
|
|
'recipient_id' => $otherRecipient->id,
|
|
'condition' => 'Bom estado',
|
|
'snipe_response' => ['status' => 'success'],
|
|
]);
|
|
Http::fake();
|
|
|
|
$this->actingAs($operator)
|
|
->post("/operacoes/{$reservation->uuid}/devolver", ['condition' => 'Bom estado'])
|
|
->assertSessionHasErrors('operation');
|
|
|
|
Http::assertNothingSent();
|
|
expect($reservation->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Delivered);
|
|
});
|
|
|
|
it('preserva sucessos e sinaliza incidente quando uma entrega fica parcial', function () {
|
|
$operator = inventoryOperator();
|
|
$first = SnipeAsset::factory()->create(['snipe_asset_id' => 701, 'status_id' => 2]);
|
|
$second = SnipeAsset::factory()->create(['snipe_asset_id' => 702, 'status_id' => 2]);
|
|
$reservation = approvedOperationReservation(User::factory()->create(), [$first, $second], 'REQ-OPS-PARTIAL');
|
|
$reservation->update(['status' => ReservationStatus::Ready]);
|
|
$reservation->items()->update(['status' => ReservationItemStatus::Ready]);
|
|
|
|
Http::fakeSequence()
|
|
->push(operationSnipeRow(701))
|
|
->push(['status' => 'success', 'payload' => ['id' => 701]])
|
|
->push(operationSnipeRow(702))
|
|
->push(['status' => 'error', 'messages' => 'Asset is not deployable'])
|
|
->push(operationSnipeRow(702))
|
|
->push(['status' => 'success', 'payload' => ['id' => 702]]);
|
|
|
|
$this->actingAs($operator)
|
|
->from('/operacoes')
|
|
->post("/operacoes/{$reservation->uuid}/entregar", [
|
|
'snipe_user_id' => 88,
|
|
'condition' => 'Verificado',
|
|
])
|
|
->assertRedirect('/operacoes')
|
|
->assertSessionHasErrors('operation');
|
|
|
|
expect($reservation->fresh()->status)->toBe(ReservationStatus::Incident)
|
|
->and($reservation->items()->orderBy('id')->pluck('status')->all())->toBe([
|
|
ReservationItemStatus::Delivered,
|
|
ReservationItemStatus::Ready,
|
|
]);
|
|
$this->assertDatabaseCount('handover_records', 1);
|
|
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.delivery_failed']);
|
|
|
|
$this->actingAs($operator)
|
|
->post("/operacoes/{$reservation->uuid}/entregar", [
|
|
'snipe_user_id' => 88,
|
|
'condition' => 'Verificado após reconciliação',
|
|
])
|
|
->assertRedirect()
|
|
->assertSessionHas('success');
|
|
|
|
expect($reservation->fresh()->status)->toBe(ReservationStatus::Delivered)
|
|
->and($reservation->items()->where('status', ReservationItemStatus::Delivered)->count())->toBe(2);
|
|
$this->assertDatabaseCount('handover_records', 2);
|
|
});
|
|
|
|
it('regista incidentes e bloqueia o equipamento pelo período indicado', function () {
|
|
$operator = inventoryOperator();
|
|
$asset = SnipeAsset::factory()->create();
|
|
$reservation = approvedOperationReservation(User::factory()->create(), [$asset], 'REQ-OPS-INCIDENT');
|
|
$reservation->update(['status' => ReservationStatus::Delivered]);
|
|
$item = $reservation->items()->firstOrFail();
|
|
$item->update(['status' => ReservationItemStatus::Delivered]);
|
|
|
|
$this->actingAs($operator)
|
|
->post("/operacoes/{$reservation->uuid}/itens/{$item->id}/incidente", [
|
|
'notes' => 'Objetiva danificada durante a utilização.',
|
|
'blocked_until' => now()->addDays(7)->toDateTimeLocalString(),
|
|
])
|
|
->assertRedirect()
|
|
->assertSessionHas('success');
|
|
|
|
expect($reservation->fresh()->status)->toBe(ReservationStatus::Incident);
|
|
$this->assertDatabaseHas('asset_blocks', [
|
|
'snipe_asset_id' => $asset->id,
|
|
'created_by' => $operator->id,
|
|
'reason' => 'Objetiva danificada durante a utilização.',
|
|
]);
|
|
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.incident_reported']);
|
|
});
|
|
|
|
it('regista faltas e atrasos sem movimentos externos', function () {
|
|
$operator = inventoryOperator();
|
|
$requester = User::factory()->create();
|
|
|
|
$noShow = approvedOperationReservation(
|
|
$requester,
|
|
[SnipeAsset::factory()->create()],
|
|
'REQ-OPS-NO-SHOW',
|
|
);
|
|
$noShow->update(['status' => ReservationStatus::Ready]);
|
|
$noShow->items()->update(['status' => ReservationItemStatus::Ready]);
|
|
|
|
$this->actingAs($operator)
|
|
->post("/operacoes/{$noShow->uuid}/falta", ['notes' => 'O requisitante não compareceu.'])
|
|
->assertRedirect()
|
|
->assertSessionHas('success');
|
|
|
|
expect($noShow->fresh()->status)->toBe(ReservationStatus::NoShow)
|
|
->and($noShow->items()->firstOrFail()->status)->toBe(ReservationItemStatus::Cancelled);
|
|
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.no_show']);
|
|
|
|
$overdue = approvedOperationReservation(
|
|
$requester,
|
|
[SnipeAsset::factory()->create()],
|
|
'REQ-OPS-OVERDUE',
|
|
);
|
|
$overdue->update(['status' => ReservationStatus::Delivered]);
|
|
$overdue->items()->update(['status' => ReservationItemStatus::Delivered]);
|
|
|
|
$this->actingAs($operator)
|
|
->post("/operacoes/{$overdue->uuid}/atraso", ['notes' => 'Prazo de devolução ultrapassado.'])
|
|
->assertRedirect()
|
|
->assertSessionHas('success');
|
|
|
|
expect($overdue->fresh()->status)->toBe(ReservationStatus::Overdue);
|
|
$this->assertDatabaseHas('audit_logs', ['event' => 'reservation.overdue']);
|
|
Http::assertNothingSent();
|
|
});
|