feat: complete approval workflow
This commit is contained in:
@@ -7,7 +7,11 @@ use App\Domain\Reservations\Enums\ReservationItemStatus;
|
||||
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||
use App\Domain\Reservations\Models\Reservation;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ReservationDecisionNotification;
|
||||
use App\Notifications\ReservationSubmittedNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -43,6 +47,7 @@ function approverUser(): User
|
||||
}
|
||||
|
||||
it('aprova uma requisição após revalidar disponibilidade', function () {
|
||||
Notification::fake();
|
||||
$approver = approverUser();
|
||||
$reservation = pendingApprovalReservation(
|
||||
User::factory()->create(),
|
||||
@@ -63,9 +68,15 @@ it('aprova uma requisição após revalidar disponibilidade', function () {
|
||||
'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(),
|
||||
@@ -79,9 +90,11 @@ it('rejeita uma requisição e liberta a pré-reserva', function () {
|
||||
|
||||
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(),
|
||||
@@ -99,6 +112,29 @@ it('regista um pedido de esclarecimentos e renova o prazo', function () {
|
||||
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 () {
|
||||
@@ -119,3 +155,96 @@ it('recusa aprovação se o estado operacional do ativo mudou', function () {
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user