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 $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(); });