Files
reservas/tests/Feature/OperationsWorkflowTest.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();
});