feat: complete equipment handover workflow
This commit is contained in:
272
tests/Feature/OperationsWorkflowTest.php
Normal file
272
tests/Feature/OperationsWorkflowTest.php
Normal file
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
use App\Domain\Identity\Enums\RoleName;
|
||||
use App\Domain\Identity\Models\Role;
|
||||
use App\Domain\Inventory\Models\SnipeAsset;
|
||||
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,
|
||||
'status' => ReservationItemStatus::Reserved,
|
||||
]);
|
||||
}
|
||||
|
||||
return $reservation;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Http::fake(['*' => Http::response(['status' => 'success', 'payload' => ['id' => 501]])]);
|
||||
|
||||
$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(1);
|
||||
});
|
||||
|
||||
it('regista a devolução no Snipe-IT e conclui o processo', function () {
|
||||
$operator = inventoryOperator();
|
||||
$asset = SnipeAsset::factory()->create([
|
||||
'snipe_asset_id' => 601,
|
||||
'status_id' => 2,
|
||||
'location_id' => 9,
|
||||
]);
|
||||
$reservation = approvedOperationReservation(User::factory()->create(), [$asset], 'REQ-OPS-RETURN');
|
||||
$reservation->update(['status' => ReservationStatus::Delivered]);
|
||||
$reservation->items()->update([
|
||||
'status' => ReservationItemStatus::Delivered,
|
||||
'delivered_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
Http::fake(['*' => Http::response(['status' => 'success', 'payload' => ['id' => 601]])]);
|
||||
|
||||
$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('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(['status' => 'success', 'payload' => ['id' => 701]])
|
||||
->push(['status' => 'error', 'messages' => 'Asset is not deployable'])
|
||||
->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();
|
||||
});
|
||||
Reference in New Issue
Block a user