feat: complete administration and reporting
This commit is contained in:
50
tests/Feature/AdminNotificationTemplateTest.php
Normal file
50
tests/Feature/AdminNotificationTemplateTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Domain\Administration\Services\NotificationTemplateService;
|
||||
use App\Domain\Identity\Enums\RoleName;
|
||||
use App\Domain\Identity\Models\Role;
|
||||
use App\Domain\Reservations\Enums\ReservationStatus;
|
||||
use App\Domain\Reservations\Models\Reservation;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ReservationDecisionNotification;
|
||||
|
||||
it('personaliza modelos e aplica variáveis às notificações', function () {
|
||||
$administrator = User::factory()->create();
|
||||
$administrator->roles()->attach(Role::firstOrCreate(['name' => RoleName::Administrator->value]));
|
||||
$requester = User::factory()->create(['name' => 'Maria Docente']);
|
||||
$reservation = Reservation::create([
|
||||
'reference' => 'REQ-TEMPLATE-001',
|
||||
'requester_id' => $requester->id,
|
||||
'purpose' => 'Validação dos modelos configuráveis.',
|
||||
'usage_location' => 'Biblioteca',
|
||||
'starts_at' => now()->addDay(),
|
||||
'ends_at' => now()->addDay()->addHours(2),
|
||||
'status' => ReservationStatus::Approved,
|
||||
])->load('requester');
|
||||
|
||||
$templates = app(NotificationTemplateService::class)->templates();
|
||||
$templates['approved'] = [
|
||||
'subject' => 'Pedido {reference} confirmado',
|
||||
'message' => 'Olá {requester}, o pedido {reference} foi confirmado. {comment}',
|
||||
];
|
||||
|
||||
$this->actingAs($administrator)
|
||||
->put('/administracao/notificacoes/modelos', ['templates' => $templates])
|
||||
->assertRedirect()
|
||||
->assertSessionHas('success');
|
||||
|
||||
$notification = new ReservationDecisionNotification($reservation, 'approved', 'Pode levantar às 09:00.');
|
||||
$mail = $notification->toMail($requester);
|
||||
$database = $notification->toArray($requester);
|
||||
|
||||
expect($mail->subject)->toBe('Pedido REQ-TEMPLATE-001 confirmado')
|
||||
->and($mail->introLines)->toContain('Olá Maria Docente, o pedido REQ-TEMPLATE-001 foi confirmado. Pode levantar às 09:00.')
|
||||
->and($database['message'])->toBe('Olá Maria Docente, o pedido REQ-TEMPLATE-001 foi confirmado. Pode levantar às 09:00.');
|
||||
$this->assertDatabaseHas('audit_logs', ['event' => 'settings.notification_templates_updated']);
|
||||
});
|
||||
|
||||
it('impede requisitantes de editar modelos de notificação', function () {
|
||||
$this->actingAs(User::factory()->create())
|
||||
->get('/administracao/notificacoes/modelos')
|
||||
->assertForbidden();
|
||||
});
|
||||
141
tests/Feature/AdminReservationPolicyTest.php
Normal file
141
tests/Feature/AdminReservationPolicyTest.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
use App\Domain\Administration\Services\ReservationPolicy;
|
||||
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\Domain\Reservations\Services\AvailabilityService;
|
||||
use App\Domain\Reservations\Services\ReservationService;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
function policyAdministrator(): User
|
||||
{
|
||||
$administrator = User::factory()->create();
|
||||
$role = Role::firstOrCreate(['name' => RoleName::Administrator->value]);
|
||||
$administrator->roles()->attach($role);
|
||||
|
||||
return $administrator;
|
||||
}
|
||||
|
||||
/** @return array<string, int> */
|
||||
function policyValues(array $overrides = []): array
|
||||
{
|
||||
return array_replace([
|
||||
'reservation.min_notice_hours' => 0,
|
||||
'reservation.max_duration_hours' => 168,
|
||||
'reservation.turnaround_minutes' => 0,
|
||||
'reservation.approval_deadline_hours' => 48,
|
||||
'reservation.max_assets' => 10,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
it('permite ao administrador alterar políticas com auditoria', function () {
|
||||
$administrator = policyAdministrator();
|
||||
$values = policyValues([
|
||||
'reservation.min_notice_hours' => 24,
|
||||
'reservation.turnaround_minutes' => 30,
|
||||
]);
|
||||
|
||||
$this->actingAs($administrator)
|
||||
->put('/administracao/regras', $values)
|
||||
->assertRedirect()
|
||||
->assertSessionHas('success');
|
||||
|
||||
expect(app(ReservationPolicy::class)->integer('reservation.min_notice_hours'))->toBe(24)
|
||||
->and(app(ReservationPolicy::class)->integer('reservation.turnaround_minutes'))->toBe(30);
|
||||
$this->assertDatabaseHas('portal_settings', [
|
||||
'key' => 'reservation.min_notice_hours',
|
||||
'updated_by' => $administrator->id,
|
||||
]);
|
||||
$this->assertDatabaseHas('audit_logs', ['event' => 'settings.reservation_policy_updated']);
|
||||
});
|
||||
|
||||
it('rejeita períodos fora da antecedência e duração configuradas', function () {
|
||||
$administrator = policyAdministrator();
|
||||
app(ReservationPolicy::class)->update(policyValues([
|
||||
'reservation.min_notice_hours' => 24,
|
||||
'reservation.max_duration_hours' => 4,
|
||||
]), $administrator);
|
||||
$requester = User::factory()->create();
|
||||
$asset = SnipeAsset::factory()->create();
|
||||
|
||||
$this->actingAs($requester)
|
||||
->from('/requisicoes/nova')
|
||||
->post('/requisicoes', [
|
||||
'purpose' => 'Atividade audiovisual com duração demasiado longa.',
|
||||
'usage_location' => 'Auditório',
|
||||
'starts_at' => now()->addHours(2)->toDateTimeString(),
|
||||
'ends_at' => now()->addHours(8)->toDateTimeString(),
|
||||
'asset_ids' => [$asset->id],
|
||||
'terms_accepted' => true,
|
||||
])
|
||||
->assertRedirect('/requisicoes/nova')
|
||||
->assertSessionHasErrors(['starts_at', 'ends_at']);
|
||||
|
||||
$this->assertDatabaseCount('reservations', 0);
|
||||
});
|
||||
|
||||
it('aplica margem temporal na consulta e na garantia de conflito', function () {
|
||||
$administrator = policyAdministrator();
|
||||
app(ReservationPolicy::class)->update(policyValues([
|
||||
'reservation.turnaround_minutes' => 60,
|
||||
]), $administrator);
|
||||
$requester = User::factory()->create();
|
||||
$asset = SnipeAsset::factory()->create();
|
||||
$reservation = Reservation::create([
|
||||
'reference' => 'REQ-POLICY-MARGIN',
|
||||
'requester_id' => $requester->id,
|
||||
'purpose' => 'Reserva existente para validar a margem.',
|
||||
'usage_location' => 'Estúdio',
|
||||
'starts_at' => '2026-09-10 09:00:00',
|
||||
'ends_at' => '2026-09-10 11:00:00',
|
||||
'status' => ReservationStatus::Approved,
|
||||
]);
|
||||
$reservation->items()->create([
|
||||
'snipe_asset_id' => $asset->id,
|
||||
'starts_at' => '2026-09-10 09:00:00',
|
||||
'ends_at' => '2026-09-10 11:00:00',
|
||||
'buffer_starts_at' => '2026-09-10 09:00:00',
|
||||
'buffer_ends_at' => '2026-09-10 11:00:00',
|
||||
'status' => ReservationItemStatus::Reserved,
|
||||
]);
|
||||
|
||||
$availability = app(AvailabilityService::class);
|
||||
expect($availability->isAvailable(
|
||||
$asset,
|
||||
new DateTimeImmutable('2026-09-10 11:30:00'),
|
||||
new DateTimeImmutable('2026-09-10 12:30:00'),
|
||||
))->toBeFalse()
|
||||
->and($availability->isAvailable(
|
||||
$asset,
|
||||
new DateTimeImmutable('2026-09-10 12:00:00'),
|
||||
new DateTimeImmutable('2026-09-10 13:00:00'),
|
||||
))->toBeTrue();
|
||||
});
|
||||
|
||||
it('grava buffers e prazo de aprovação definidos pela política', function () {
|
||||
$administrator = policyAdministrator();
|
||||
app(ReservationPolicy::class)->update(policyValues([
|
||||
'reservation.turnaround_minutes' => 30,
|
||||
'reservation.approval_deadline_hours' => 12,
|
||||
]), $administrator);
|
||||
$startsAt = CarbonImmutable::parse('2026-09-20 10:00:00');
|
||||
$endsAt = $startsAt->addHours(2);
|
||||
|
||||
$reservation = app(ReservationService::class)->createPending(User::factory()->create(), [
|
||||
'purpose' => 'Atividade para validar políticas configuráveis.',
|
||||
'usage_location' => 'Sala multimédia',
|
||||
'starts_at' => $startsAt->toDateTimeString(),
|
||||
'ends_at' => $endsAt->toDateTimeString(),
|
||||
'asset_ids' => [SnipeAsset::factory()->create()->id],
|
||||
]);
|
||||
$item = $reservation->items()->firstOrFail();
|
||||
|
||||
expect($item->buffer_starts_at->equalTo($startsAt->subMinutes(30)))->toBeTrue()
|
||||
->and($item->buffer_ends_at->equalTo($endsAt->addMinutes(30)))->toBeTrue()
|
||||
->and($reservation->created_at->diffInHours($reservation->approval_deadline))->toBe(12.0);
|
||||
});
|
||||
117
tests/Feature/AdminUserManagementTest.php
Normal file
117
tests/Feature/AdminUserManagementTest.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use App\Domain\Identity\Enums\RoleName;
|
||||
use App\Domain\Identity\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
function userManagementAdministrator(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$role = Role::firstOrCreate(['name' => RoleName::Administrator->value]);
|
||||
$user->roles()->attach($role);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function ensureManagementRoles(): void
|
||||
{
|
||||
foreach (RoleName::cases() as $role) {
|
||||
Role::firstOrCreate(['name' => $role->value]);
|
||||
}
|
||||
}
|
||||
|
||||
it('restringe a gestão de utilizadores a administradores', function () {
|
||||
$this->actingAs(User::factory()->create())
|
||||
->get('/administracao/utilizadores')
|
||||
->assertForbidden();
|
||||
|
||||
ensureManagementRoles();
|
||||
$this->actingAs(userManagementAdministrator())
|
||||
->get('/administracao/utilizadores')
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/users/index')
|
||||
->has('roles', count(RoleName::cases())));
|
||||
});
|
||||
|
||||
it('cria uma conta verificada com perfis e associação Snipe-IT', function () {
|
||||
ensureManagementRoles();
|
||||
$administrator = userManagementAdministrator();
|
||||
|
||||
$this->actingAs($administrator)
|
||||
->post('/administracao/utilizadores', [
|
||||
'name' => 'Operadora de Inventário',
|
||||
'email' => 'operadora@escola.pt',
|
||||
'department' => 'Serviços de Informática',
|
||||
'snipe_user_id' => 172,
|
||||
'password' => 'temporaria-segura-2026',
|
||||
'active' => true,
|
||||
'roles' => [RoleName::Requester->value, RoleName::InventoryOperator->value],
|
||||
])
|
||||
->assertRedirect()
|
||||
->assertSessionHas('success');
|
||||
|
||||
$user = User::query()->where('email', 'operadora@escola.pt')->firstOrFail();
|
||||
expect($user->email_verified_at)->not->toBeNull()
|
||||
->and($user->snipe_user_id)->toBe(172)
|
||||
->and($user->roles()->pluck('name')->sort()->values()->all())->toBe([
|
||||
RoleName::InventoryOperator->value,
|
||||
RoleName::Requester->value,
|
||||
]);
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'actor_id' => $administrator->id,
|
||||
'event' => 'user.created',
|
||||
'auditable_id' => $user->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('atualiza dados perfis estado e palavra-passe com auditoria', function () {
|
||||
ensureManagementRoles();
|
||||
$administrator = userManagementAdministrator();
|
||||
$user = User::factory()->create();
|
||||
$user->roles()->attach(Role::query()->where('name', RoleName::Requester->value)->firstOrFail());
|
||||
|
||||
$this->actingAs($administrator)
|
||||
->put("/administracao/utilizadores/{$user->id}", [
|
||||
'name' => 'Utilizador Atualizado',
|
||||
'email' => $user->email,
|
||||
'department' => 'Direção',
|
||||
'snipe_user_id' => 88,
|
||||
'password' => 'nova-palavra-passe-2026',
|
||||
'active' => false,
|
||||
'roles' => [RoleName::Auditor->value],
|
||||
])
|
||||
->assertRedirect()
|
||||
->assertSessionHas('success');
|
||||
|
||||
$user->refresh();
|
||||
expect($user->name)->toBe('Utilizador Atualizado')
|
||||
->and($user->active)->toBeFalse()
|
||||
->and($user->hasRole(RoleName::Auditor))->toBeTrue()
|
||||
->and(Hash::check('nova-palavra-passe-2026', $user->password))->toBeTrue();
|
||||
$this->assertDatabaseHas('audit_logs', ['event' => 'user.updated', 'auditable_id' => $user->id]);
|
||||
});
|
||||
|
||||
it('impede o administrador de remover o próprio acesso', function () {
|
||||
ensureManagementRoles();
|
||||
$administrator = userManagementAdministrator();
|
||||
|
||||
$this->actingAs($administrator)
|
||||
->from('/administracao/utilizadores')
|
||||
->put("/administracao/utilizadores/{$administrator->id}", [
|
||||
'name' => $administrator->name,
|
||||
'email' => $administrator->email,
|
||||
'department' => $administrator->department,
|
||||
'snipe_user_id' => null,
|
||||
'password' => null,
|
||||
'active' => false,
|
||||
'roles' => [RoleName::Requester->value],
|
||||
])
|
||||
->assertRedirect('/administracao/utilizadores')
|
||||
->assertSessionHasErrors('roles');
|
||||
|
||||
expect($administrator->fresh()->active)->toBeTrue()
|
||||
->and($administrator->hasRole(RoleName::Administrator))->toBeTrue();
|
||||
});
|
||||
120
tests/Feature/PhaseSixReportingTest.php
Normal file
120
tests/Feature/PhaseSixReportingTest.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
use App\Domain\Audit\Models\AuditLog;
|
||||
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 Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
function phaseSixUser(RoleName $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->roles()->attach(Role::firstOrCreate(['name' => $role->value]));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function completedReportedReservation(User $requester): Reservation
|
||||
{
|
||||
$asset = SnipeAsset::factory()->create([
|
||||
'asset_tag' => 'REPORT-001',
|
||||
'name' => 'Câmara de relatório',
|
||||
'category_name' => 'Fotografia',
|
||||
]);
|
||||
$reservation = Reservation::create([
|
||||
'reference' => 'REQ-REPORT-001',
|
||||
'requester_id' => $requester->id,
|
||||
'purpose' => 'Produção de conteúdos para relatório.',
|
||||
'usage_location' => 'Estúdio',
|
||||
'starts_at' => now()->subDays(2),
|
||||
'ends_at' => now()->subDays(2)->addHours(3),
|
||||
'status' => ReservationStatus::Completed,
|
||||
]);
|
||||
$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::Returned,
|
||||
'delivered_at' => $reservation->starts_at,
|
||||
'returned_at' => $reservation->ends_at,
|
||||
]);
|
||||
|
||||
return $reservation;
|
||||
}
|
||||
|
||||
it('apresenta o calendário global apenas a perfis autorizados', function () {
|
||||
$requester = phaseSixUser(RoleName::Requester);
|
||||
$reservation = completedReportedReservation($requester);
|
||||
$month = $reservation->starts_at->format('Y-m');
|
||||
|
||||
$this->actingAs($requester)->get("/calendario-global?month={$month}")->assertForbidden();
|
||||
|
||||
$this->actingAs(phaseSixUser(RoleName::Approver))
|
||||
->get("/calendario-global?month={$month}")
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('reservations/calendar')
|
||||
->where('scope', 'global')
|
||||
->has('reservations', 1)
|
||||
->where('reservations.0.reference', 'REQ-REPORT-001')
|
||||
->where('reservations.0.requester.id', $requester->id));
|
||||
});
|
||||
|
||||
it('calcula indicadores e exporta detalhe em CSV', function () {
|
||||
$requester = User::factory()->create([
|
||||
'name' => '=HYPERLINK("https://invalid.test")',
|
||||
'department' => 'Comunicação',
|
||||
]);
|
||||
completedReportedReservation($requester);
|
||||
$auditor = phaseSixUser(RoleName::Auditor);
|
||||
$query = http_build_query([
|
||||
'date_from' => now()->subDays(7)->toDateString(),
|
||||
'date_to' => now()->toDateString(),
|
||||
]);
|
||||
|
||||
$this->actingAs($auditor)
|
||||
->get("/relatorios?{$query}")
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('reports/index')
|
||||
->where('summary.total', 1)
|
||||
->where('summary.completed', 1)
|
||||
->where('summary.utilizationHours', 3)
|
||||
->where('byCategory.Fotografia', 1)
|
||||
->where('byDepartment.Comunicação', 1));
|
||||
|
||||
$response = $this->actingAs($auditor)->get("/relatorios/exportar?{$query}");
|
||||
$response->assertOk()->assertDownload();
|
||||
expect($response->streamedContent())->toContain(
|
||||
'REQ-REPORT-001',
|
||||
'REPORT-001',
|
||||
'Comunicação',
|
||||
"'=HYPERLINK",
|
||||
);
|
||||
});
|
||||
|
||||
it('disponibiliza auditoria imutável a auditores e administradores', function () {
|
||||
$actor = User::factory()->create();
|
||||
AuditLog::create([
|
||||
'actor_id' => $actor->id,
|
||||
'event' => 'reservation.test_audit',
|
||||
'metadata' => ['reference' => 'REQ-AUDIT-001'],
|
||||
]);
|
||||
|
||||
$this->actingAs(User::factory()->create())->get('/auditoria')->assertForbidden();
|
||||
|
||||
$this->actingAs(phaseSixUser(RoleName::Auditor))
|
||||
->get('/auditoria?event=test_audit')
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('audit/index')
|
||||
->has('logs.data', 1)
|
||||
->where('logs.data.0.event', 'reservation.test_audit')
|
||||
->where('logs.data.0.actor.name', $actor->name));
|
||||
});
|
||||
Reference in New Issue
Block a user