$reference, 'requester_id' => $requester->id, 'purpose' => 'Produção audiovisual para uma atividade pedagógica.', 'usage_location' => 'Auditório', 'starts_at' => now()->addDays(3), 'ends_at' => now()->addDays(3)->addHours(2), 'status' => ReservationStatus::PendingApproval, 'approval_deadline' => now()->addDay(), ]); $reservation->items()->create([ 'snipe_asset_id' => $asset->id, 'starts_at' => $reservation->starts_at, 'ends_at' => $reservation->ends_at, 'status' => ReservationItemStatus::Held, ]); return $reservation; } function approverUser(): User { $user = User::factory()->create(); $role = Role::create(['name' => RoleName::Approver->value]); $user->roles()->attach($role); return $user; } it('aprova uma requisição após revalidar disponibilidade', function () { Notification::fake(); $approver = approverUser(); $reservation = pendingApprovalReservation( User::factory()->create(), SnipeAsset::factory()->create(), 'REQ-APPROVE-001', ); $this->actingAs($approver) ->post("/aprovacoes/{$reservation->uuid}/aprovar", ['comment' => 'Pedido adequado.']) ->assertRedirect() ->assertSessionHas('success'); expect($reservation->fresh()->status)->toBe(ReservationStatus::Approved) ->and($reservation->items()->first()->status)->toBe(ReservationItemStatus::Reserved); $this->assertDatabaseHas('approval_actions', [ 'reservation_id' => $reservation->id, 'actor_id' => $approver->id, 'action' => 'approved', ]); $this->assertDatabaseHas('audit_logs', ['event' => 'reservation.approved']); Notification::assertSentTo( $reservation->requester, ReservationDecisionNotification::class, fn (ReservationDecisionNotification $notification): bool => $notification->event === 'approved', ); }); it('rejeita uma requisição e liberta a pré-reserva', function () { Notification::fake(); $approver = approverUser(); $reservation = pendingApprovalReservation( User::factory()->create(), SnipeAsset::factory()->create(), 'REQ-APPROVE-002', ); $this->actingAs($approver) ->post("/aprovacoes/{$reservation->uuid}/rejeitar", ['comment' => 'O equipamento não pode sair da escola.']) ->assertRedirect(); expect($reservation->fresh()->status)->toBe(ReservationStatus::Rejected) ->and($reservation->items()->first()->status)->toBe(ReservationItemStatus::Cancelled); Notification::assertSentTo($reservation->requester, ReservationDecisionNotification::class); }); it('regista um pedido de esclarecimentos e renova o prazo', function () { Notification::fake(); $approver = approverUser(); $reservation = pendingApprovalReservation( User::factory()->create(), SnipeAsset::factory()->create(), 'REQ-APPROVE-003', ); $oldDeadline = $reservation->approval_deadline; $this->actingAs($approver) ->post("/aprovacoes/{$reservation->uuid}/pedir-esclarecimentos", [ 'comment' => 'Indique quem ficará responsável pelo transporte.', ]) ->assertRedirect(); expect($reservation->fresh()->status)->toBe(ReservationStatus::AwaitingInformation) ->and($reservation->fresh()->approval_deadline->isAfter($oldDeadline))->toBeTrue(); $this->assertDatabaseHas('approval_actions', ['action' => 'information_requested']); Notification::assertSentTo( $reservation->requester, ReservationDecisionNotification::class, fn (ReservationDecisionNotification $notification): bool => $notification->event === 'information_requested', ); $this->actingAs($reservation->requester) ->post("/requisicoes/{$reservation->uuid}/responder", [ 'response' => 'A docente responsável fará o transporte em caixa protegida.', ]) ->assertRedirect() ->assertSessionHas('success'); expect($reservation->fresh()->status)->toBe(ReservationStatus::PendingApproval); $this->assertDatabaseHas('approval_actions', [ 'reservation_id' => $reservation->id, 'action' => 'information_provided', ]); Notification::assertSentTo( $approver, ReservationSubmittedNotification::class, fn (ReservationSubmittedNotification $notification): bool => $notification->event === 'information_provided', ); }); it('impede requisitantes de aceder à área de aprovação', function () { $this->actingAs(User::factory()->create())->get('/aprovacoes')->assertForbidden(); }); it('recusa aprovação se o estado operacional do ativo mudou', function () { $approver = approverUser(); $asset = SnipeAsset::factory()->create(); $reservation = pendingApprovalReservation(User::factory()->create(), $asset, 'REQ-APPROVE-004'); $asset->update(['assigned_id' => 99]); $this->actingAs($approver) ->from('/aprovacoes') ->post("/aprovacoes/{$reservation->uuid}/aprovar") ->assertRedirect('/aprovacoes') ->assertSessionHasErrors('decision'); expect($reservation->fresh()->status)->toBe(ReservationStatus::PendingApproval); }); it('substitui um equipamento por outro disponível da mesma categoria', function () { Notification::fake(); $approver = approverUser(); $original = SnipeAsset::factory()->create(['category_id' => 7, 'category_name' => 'Vídeo']); $replacement = SnipeAsset::factory()->create(['category_id' => 7, 'category_name' => 'Vídeo']); $reservation = pendingApprovalReservation(User::factory()->create(), $original, 'REQ-SWAP-001'); $item = $reservation->items()->firstOrFail(); $this->actingAs($approver) ->post("/aprovacoes/{$reservation->uuid}/itens/{$item->id}/substituir", [ 'replacement_asset_id' => $replacement->id, 'comment' => 'O modelo alternativo tem a ligação necessária.', ]) ->assertRedirect() ->assertSessionHas('success'); expect($item->fresh()->status)->toBe(ReservationItemStatus::Cancelled); $this->assertDatabaseHas('reservation_items', [ 'reservation_id' => $reservation->id, 'snipe_asset_id' => $replacement->id, 'status' => ReservationItemStatus::Held->value, ]); $this->assertDatabaseHas('approval_actions', ['action' => 'asset_substituted']); $this->assertDatabaseHas('audit_logs', ['event' => 'reservation.asset_substituted']); Notification::assertSentTo( $reservation->requester, ReservationDecisionNotification::class, fn (ReservationDecisionNotification $notification): bool => $notification->event === 'asset_substituted', ); }); it('recusa substituição por equipamento de outra categoria', function () { $approver = approverUser(); $original = SnipeAsset::factory()->create(['category_id' => 7, 'category_name' => 'Vídeo']); $replacement = SnipeAsset::factory()->create(['category_id' => 8, 'category_name' => 'Áudio']); $reservation = pendingApprovalReservation(User::factory()->create(), $original, 'REQ-SWAP-002'); $item = $reservation->items()->firstOrFail(); $this->actingAs($approver) ->from('/aprovacoes') ->post("/aprovacoes/{$reservation->uuid}/itens/{$item->id}/substituir", [ 'replacement_asset_id' => $replacement->id, 'comment' => 'Tentativa de troca entre categorias diferentes.', ]) ->assertRedirect('/aprovacoes') ->assertSessionHasErrors('replacement_asset_id'); expect($item->fresh()->status)->toBe(ReservationItemStatus::Held); }); it('filtra a fila de aprovação por categoria e período', function () { $approver = approverUser(); $requester = User::factory()->create(); pendingApprovalReservation( $requester, SnipeAsset::factory()->create(['category_name' => 'Projetores']), 'REQ-FILTER-PROJECTOR', ); pendingApprovalReservation( $requester, SnipeAsset::factory()->create(['category_name' => 'Áudio']), 'REQ-FILTER-AUDIO', ); $query = http_build_query([ 'category' => 'Projetores', 'date_from' => now()->addDays(2)->toDateString(), 'date_to' => now()->addDays(4)->toDateString(), ]); $this->actingAs($approver) ->get("/aprovacoes?{$query}") ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('approvals/index') ->has('reservations.data', 1) ->where('reservations.data.0.reference', 'REQ-FILTER-PROJECTOR')); }); it('impede outro utilizador de responder aos esclarecimentos', function () { $reservation = pendingApprovalReservation( User::factory()->create(), SnipeAsset::factory()->create(), 'REQ-INFO-IDOR', ); $reservation->update(['status' => ReservationStatus::AwaitingInformation]); $this->actingAs(User::factory()->create()) ->post("/requisicoes/{$reservation->uuid}/responder", [ 'response' => 'Tentativa de resposta por outro utilizador.', ]) ->assertForbidden(); });