first
This commit is contained in:
189
tests/Feature/Admin/ElectionSettingsTest.php
Normal file
189
tests/Feature/Admin/ElectionSettingsTest.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Admin;
|
||||
|
||||
use App\Models\Election;
|
||||
use App\Models\ElectionSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ElectionSettingsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_admin_can_view_settings_page(): void
|
||||
{
|
||||
$this->seed();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Admin');
|
||||
$election = Election::query()->firstOrFail();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.setup.settings.edit', $election))
|
||||
->assertOk()
|
||||
->assertSee('Tetapan Pilihanraya')
|
||||
->assertSee('Mod Kehadiran')
|
||||
->assertSee('Override Tempoh Pendaftaran');
|
||||
}
|
||||
|
||||
public function test_admin_can_activate_attendance(): void
|
||||
{
|
||||
$this->seed();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Admin');
|
||||
$election = Election::query()->firstOrFail();
|
||||
$election->settings()->update(['is_attendance_active' => false]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.setup.settings.update', $election), [
|
||||
'is_attendance_active' => '1',
|
||||
'is_registration_open_override' => '',
|
||||
])
|
||||
->assertRedirect(route('admin.setup.settings.edit', $election));
|
||||
|
||||
$this->assertTrue(
|
||||
ElectionSetting::query()->where('election_id', $election->id)->value('is_attendance_active')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_admin_can_deactivate_attendance(): void
|
||||
{
|
||||
$this->seed();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Admin');
|
||||
$election = Election::query()->firstOrFail();
|
||||
$election->settings()->update(['is_attendance_active' => true]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.setup.settings.update', $election), [
|
||||
'is_attendance_active' => '0',
|
||||
'is_registration_open_override' => '',
|
||||
])
|
||||
->assertRedirect(route('admin.setup.settings.edit', $election));
|
||||
|
||||
$this->assertFalse(
|
||||
ElectionSetting::query()->where('election_id', $election->id)->value('is_attendance_active')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_admin_can_force_open_registration(): void
|
||||
{
|
||||
$this->seed();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Admin');
|
||||
$election = Election::query()->firstOrFail();
|
||||
$election->settings()->update(['is_registration_open_override' => null]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.setup.settings.update', $election), [
|
||||
'is_attendance_active' => '0',
|
||||
'is_registration_open_override' => '1',
|
||||
])
|
||||
->assertRedirect(route('admin.setup.settings.edit', $election));
|
||||
|
||||
$this->assertTrue(
|
||||
ElectionSetting::query()->where('election_id', $election->id)->value('is_registration_open_override')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_admin_can_force_close_registration(): void
|
||||
{
|
||||
$this->seed();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Admin');
|
||||
$election = Election::query()->firstOrFail();
|
||||
$election->settings()->update(['is_registration_open_override' => null]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.setup.settings.update', $election), [
|
||||
'is_attendance_active' => '0',
|
||||
'is_registration_open_override' => '0',
|
||||
])
|
||||
->assertRedirect(route('admin.setup.settings.edit', $election));
|
||||
|
||||
$this->assertFalse(
|
||||
ElectionSetting::query()->where('election_id', $election->id)->value('is_registration_open_override')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_admin_can_reset_registration_override_to_auto(): void
|
||||
{
|
||||
$this->seed();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Admin');
|
||||
$election = Election::query()->firstOrFail();
|
||||
$election->settings()->update(['is_registration_open_override' => true]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.setup.settings.update', $election), [
|
||||
'is_attendance_active' => '0',
|
||||
'is_registration_open_override' => '',
|
||||
])
|
||||
->assertRedirect(route('admin.setup.settings.edit', $election));
|
||||
|
||||
$this->assertNull(
|
||||
ElectionSetting::query()->where('election_id', $election->id)->value('is_registration_open_override')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_access_settings_page(): void
|
||||
{
|
||||
$this->seed();
|
||||
|
||||
$election = Election::query()->firstOrFail();
|
||||
|
||||
foreach (['Admin Kewangan', 'PPM', 'KTM'] as $role) {
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($role);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.setup.settings.edit', $election))
|
||||
->assertForbidden();
|
||||
}
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_update_settings(): void
|
||||
{
|
||||
$this->seed();
|
||||
|
||||
$election = Election::query()->firstOrFail();
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('Admin Kewangan');
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('admin.setup.settings.update', $election), [
|
||||
'is_attendance_active' => '1',
|
||||
'is_registration_open_override' => '',
|
||||
])
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_settings_change_is_recorded_in_activity_log(): void
|
||||
{
|
||||
$this->seed();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Admin');
|
||||
$election = Election::query()->firstOrFail();
|
||||
$election->settings()->update(['is_attendance_active' => false]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.setup.settings.update', $election), [
|
||||
'is_attendance_active' => '1',
|
||||
'is_registration_open_override' => '',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('activity_log', [
|
||||
'log_name' => 'admin_settings',
|
||||
'description' => 'election_settings_updated',
|
||||
'causer_id' => $admin->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
83
tests/Feature/Admin/PusatOperationalSetupTest.php
Normal file
83
tests/Feature/Admin/PusatOperationalSetupTest.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Admin;
|
||||
|
||||
use App\Models\DaerahMengundi;
|
||||
use App\Models\Election;
|
||||
use App\Models\Position;
|
||||
use App\Models\PositionQuota;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\SaluranMengundi;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PusatOperationalSetupTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_admin_can_create_pusat_with_numbered_saluran_and_operational_quotas(): void
|
||||
{
|
||||
$this->seed();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Admin');
|
||||
$election = Election::query()->firstOrFail();
|
||||
$daerah = DaerahMengundi::query()->where('election_id', $election->id)->firstOrFail();
|
||||
|
||||
$response = $this
|
||||
->actingAs($admin)
|
||||
->post(route('admin.setup.pusat.store'), [
|
||||
'election_id' => $election->id,
|
||||
'daerah_mengundi_id' => $daerah->id,
|
||||
'code' => 'PMAUTO',
|
||||
'name' => 'Pusat Mengundi Automatik',
|
||||
'address' => 'Alamat ujian',
|
||||
'saluran_count' => 2,
|
||||
'ktm_count' => 1,
|
||||
'polis_iring_count' => 1,
|
||||
'kp_count' => 4,
|
||||
'ppm_count' => 1,
|
||||
'kpdp_count' => 1,
|
||||
'papm_count' => 3,
|
||||
'jkm_count' => 1,
|
||||
'kkm_count' => 1,
|
||||
'calon_tambahan_count' => 2,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('admin.setup.pusat.index'));
|
||||
|
||||
$pusat = PusatMengundi::query()->where('code', 'PMAUTO')->firstOrFail();
|
||||
$salurans = SaluranMengundi::query()->where('pusat_mengundi_id', $pusat->id)->get();
|
||||
$this->assertCount(2, $salurans);
|
||||
|
||||
$salurans->each(function (SaluranMengundi $saluran): void {
|
||||
$this->assertNull($saluran->name);
|
||||
|
||||
$this->assertQuota($saluran->pusat_mengundi_id, $saluran->id, 'KTM', 1);
|
||||
$this->assertQuota($saluran->pusat_mengundi_id, $saluran->id, 'POLIS', 1);
|
||||
$this->assertQuota($saluran->pusat_mengundi_id, $saluran->id, 'KP', 4);
|
||||
$this->assertQuota($saluran->pusat_mengundi_id, $saluran->id, 'KPDP', 1);
|
||||
$this->assertQuota($saluran->pusat_mengundi_id, $saluran->id, 'CALON_TAMBAHAN', 2);
|
||||
});
|
||||
|
||||
$this->assertQuota($pusat->id, null, 'PPM', 1);
|
||||
$this->assertQuota($pusat->id, null, 'PAPM', 3);
|
||||
$this->assertQuota($pusat->id, null, 'JKM', 1);
|
||||
$this->assertQuota($pusat->id, null, 'KKM', 1);
|
||||
}
|
||||
|
||||
private function assertQuota(int $pusatId, ?int $saluranId, string $positionCode, int $quota): void
|
||||
{
|
||||
$positionId = Position::query()->where('code', $positionCode)->value('id');
|
||||
|
||||
$this->assertSame(
|
||||
$quota,
|
||||
PositionQuota::query()
|
||||
->where('pusat_mengundi_id', $pusatId)
|
||||
->where('saluran_mengundi_id', $saluranId)
|
||||
->where('position_id', $positionId)
|
||||
->value('quota'),
|
||||
);
|
||||
}
|
||||
}
|
||||
347
tests/Feature/AdminManagementTest.php
Normal file
347
tests/Feature/AdminManagementTest.php
Normal file
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Election;
|
||||
use App\Models\JkmRepresentative;
|
||||
use App\Models\Position;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\SystemNote;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminManagementTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_admin_can_create_manual_application(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.management.applications.store'), [
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'requested_position_id' => $position->id,
|
||||
'name' => 'Manual Admin',
|
||||
'ic_number' => '900101105555',
|
||||
'phone_number' => '0123456789',
|
||||
'email' => 'manual@example.test',
|
||||
'bank_name' => 'Maybank',
|
||||
'bank_account_number' => '1234567890',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$application = Application::query()->where('ic_number', '900101105555')->firstOrFail();
|
||||
|
||||
$this->assertSame('admin_manual', $application->source);
|
||||
$this->assertSame('submitted', $application->status);
|
||||
$this->assertTrue($application->bankVerification()->exists());
|
||||
}
|
||||
|
||||
public function test_admin_edit_after_registration_closed_requires_catatan(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
$application = Application::factory()->create([
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'requested_position_id' => $position->id,
|
||||
'ic_number' => '900101105556',
|
||||
]);
|
||||
$this->closeRegistration($pusat->election);
|
||||
|
||||
$payload = [
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'requested_position_id' => $position->id,
|
||||
'status' => 'submitted',
|
||||
'name' => 'Manual Edit',
|
||||
'ic_number' => '900101105556',
|
||||
'phone_number' => '0123456789',
|
||||
'email' => 'manual-edit@example.test',
|
||||
];
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.management.applications.update', $application->public_uuid), $payload)
|
||||
->assertSessionHasErrors('note');
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.management.applications.update', $application->public_uuid), [
|
||||
...$payload,
|
||||
'note' => 'Pembetulan selepas tutup pendaftaran.',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertTrue(SystemNote::query()
|
||||
->where('noteable_type', Application::class)
|
||||
->where('noteable_id', $application->id)
|
||||
->where('note_type', 'admin_application_edit_after_close')
|
||||
->exists());
|
||||
}
|
||||
|
||||
public function test_admin_can_direct_assign_application_after_close_with_catatan(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
$application = Application::factory()->create([
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'requested_position_id' => $position->id,
|
||||
'ic_number' => '900101105557',
|
||||
]);
|
||||
$this->closeRegistration($pusat->election);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.management.applications.assign', $application->public_uuid), [
|
||||
'position_id' => $position->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'status' => 'active',
|
||||
])
|
||||
->assertSessionHasErrors('note');
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.management.applications.assign', $application->public_uuid), [
|
||||
'position_id' => $position->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'status' => 'active',
|
||||
'note' => 'Lantikan manual selepas tutup.',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$application->refresh();
|
||||
|
||||
$this->assertSame('assigned', $application->status);
|
||||
$this->assertTrue(StaffAssignment::query()
|
||||
->where('application_id', $application->id)
|
||||
->where('position_id', $position->id)
|
||||
->where('source', 'admin_direct')
|
||||
->exists());
|
||||
$this->assertTrue(SystemNote::query()->where('note_type', 'admin_assignment_after_close')->exists());
|
||||
}
|
||||
|
||||
public function test_admin_can_create_representative_record(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.management.representatives.store'), [
|
||||
'type' => 'jkm',
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'name' => 'Wakil JKM',
|
||||
'ic_number' => '900101105558',
|
||||
'phone_number' => '0123456789',
|
||||
'agency' => 'JKM',
|
||||
])
|
||||
->assertRedirect(route('admin.management.representatives.index'));
|
||||
|
||||
$this->assertTrue(JkmRepresentative::query()->where('name', 'Wakil JKM')->exists());
|
||||
}
|
||||
|
||||
public function test_admin_kewangan_cannot_access_admin_management_routes(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail();
|
||||
|
||||
$this->actingAs($finance)
|
||||
->get(route('admin.management.assignments.index'))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_admin_create_application_after_registration_closed_requires_catatan(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
$this->closeRegistration($pusat->election);
|
||||
|
||||
$payload = [
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'requested_position_id' => $position->id,
|
||||
'name' => 'Manual Post-Close',
|
||||
'ic_number' => '900101105560',
|
||||
'phone_number' => '0123456789',
|
||||
'email' => 'post-close@example.test',
|
||||
'bank_name' => 'Maybank',
|
||||
'bank_account_number' => '1234567890',
|
||||
];
|
||||
|
||||
// Without note — both middleware and service should reject
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.management.applications.store'), $payload)
|
||||
->assertSessionHasErrors('note');
|
||||
|
||||
$this->assertDatabaseMissing('applications', ['ic_number' => '900101105560']);
|
||||
|
||||
// With note — must succeed
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.management.applications.store'), [
|
||||
...$payload,
|
||||
'note' => 'Manual entry selepas tutup pendaftaran.',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('applications', ['ic_number' => '900101105560']);
|
||||
}
|
||||
|
||||
public function test_admin_update_assignment_after_registration_closed_requires_catatan(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
|
||||
$assignment = StaffAssignment::query()
|
||||
->where('election_id', $pusat->election_id)
|
||||
->where('pusat_mengundi_id', $pusat->id)
|
||||
->where('status', 'active')
|
||||
->firstOrFail();
|
||||
|
||||
$this->closeRegistration($pusat->election);
|
||||
|
||||
$payload = [
|
||||
'position_id' => $assignment->position_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'status' => 'active',
|
||||
];
|
||||
|
||||
// Without note
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.management.assignments.update', $assignment), $payload)
|
||||
->assertSessionHasErrors('note');
|
||||
|
||||
// With note
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.management.assignments.update', $assignment), [
|
||||
...$payload,
|
||||
'note' => 'Kemaskini tugasan selepas tutup.',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertTrue(SystemNote::query()
|
||||
->where('note_type', 'admin_assignment_edit_after_close')
|
||||
->exists());
|
||||
}
|
||||
|
||||
public function test_admin_create_representative_after_registration_closed_requires_catatan(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$this->closeRegistration($pusat->election);
|
||||
|
||||
$payload = [
|
||||
'type' => 'kkm',
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'name' => 'Wakil KKM Post-Close',
|
||||
];
|
||||
|
||||
// Without note
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.management.representatives.store'), $payload)
|
||||
->assertSessionHasErrors('note');
|
||||
|
||||
// With note
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.management.representatives.store'), [
|
||||
...$payload,
|
||||
'note' => 'Tambah wakil selepas tutup.',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('kkm_representatives', ['name' => 'Wakil KKM Post-Close']);
|
||||
}
|
||||
|
||||
public function test_admin_delete_representative_after_registration_closed_requires_catatan(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
|
||||
$jkm = JkmRepresentative::query()->create([
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'name' => 'JKM Untuk Padam',
|
||||
]);
|
||||
|
||||
$this->closeRegistration($pusat->election);
|
||||
|
||||
// Without note — middleware blocks before service runs
|
||||
$this->actingAs($admin)
|
||||
->delete(route('admin.management.representatives.destroy', ['type' => 'jkm', 'id' => $jkm->id]))
|
||||
->assertSessionHasErrors('note');
|
||||
|
||||
$this->assertDatabaseHas('jkm_representatives', ['id' => $jkm->id]);
|
||||
|
||||
// With note — service enforces its own check too
|
||||
$this->actingAs($admin)
|
||||
->delete(route('admin.management.representatives.destroy', ['type' => 'jkm', 'id' => $jkm->id]), [
|
||||
'note' => 'Padam wakil selepas tutup.',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertSoftDeleted('jkm_representatives', ['id' => $jkm->id]);
|
||||
}
|
||||
|
||||
public function test_middleware_does_not_block_when_registration_is_open(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
|
||||
// Registration is open by default in seed; no note required
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.management.applications.store'), [
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'requested_position_id' => $position->id,
|
||||
'name' => 'Admin Open',
|
||||
'ic_number' => '900101105561',
|
||||
'phone_number' => '0123456789',
|
||||
'email' => 'admin-open@example.test',
|
||||
'bank_name' => 'Maybank',
|
||||
'bank_account_number' => '1234567890',
|
||||
])
|
||||
->assertRedirect()
|
||||
->assertSessionMissing('errors');
|
||||
|
||||
$this->assertDatabaseHas('applications', ['ic_number' => '900101105561']);
|
||||
}
|
||||
|
||||
private function closeRegistration(Election $election): void
|
||||
{
|
||||
$election->settings()->update([
|
||||
'registration_start_date' => now()->subWeeks(4)->toDateString(),
|
||||
'registration_end_date' => now()->subDay()->toDateString(),
|
||||
'is_registration_open' => true,
|
||||
'is_registration_open_override' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
168
tests/Feature/AdminSetupTest.php
Normal file
168
tests/Feature/AdminSetupTest.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\BahagianPilihanraya;
|
||||
use App\Models\DaerahMengundi;
|
||||
use App\Models\Election;
|
||||
use App\Models\Position;
|
||||
use App\Models\PositionQuota;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\SaluranMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminSetupTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_admin_can_create_election_hierarchy_records_and_generate_pusat_qr(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$election = Election::query()->where('code', 'PRN2026')->firstOrFail();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.setup.bahagian.store'), [
|
||||
'election_id' => $election->id,
|
||||
'code' => 'B999',
|
||||
'name' => 'Bahagian Ujian',
|
||||
])
|
||||
->assertRedirect(route('admin.setup.bahagian.index'));
|
||||
|
||||
$bahagian = BahagianPilihanraya::query()->where('code', 'B999')->firstOrFail();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.setup.daerah.store'), [
|
||||
'election_id' => $election->id,
|
||||
'bahagian_pilihanraya_id' => $bahagian->id,
|
||||
'code' => 'D999',
|
||||
'name' => 'Daerah Ujian',
|
||||
])
|
||||
->assertRedirect(route('admin.setup.daerah.index'));
|
||||
|
||||
$daerah = DaerahMengundi::query()->where('code', 'D999')->firstOrFail();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.setup.pusat.store'), [
|
||||
'election_id' => $election->id,
|
||||
'daerah_mengundi_id' => $daerah->id,
|
||||
'code' => 'PM999',
|
||||
'name' => 'SK Ujian',
|
||||
'address' => 'Jalan Ujian',
|
||||
'saluran_count' => 2,
|
||||
'ktm_count' => 1,
|
||||
'polis_iring_count' => 1,
|
||||
'kp_count' => 4,
|
||||
'ppm_count' => 1,
|
||||
'kpdp_count' => 1,
|
||||
'papm_count' => 3,
|
||||
'jkm_count' => 1,
|
||||
'kkm_count' => 1,
|
||||
'calon_tambahan_count' => 1,
|
||||
])
|
||||
->assertRedirect(route('admin.setup.pusat.index'));
|
||||
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM999')->firstOrFail();
|
||||
|
||||
$this->assertNotNull($pusat->public_uuid);
|
||||
$this->assertStringContainsString('/pohon/'.$pusat->public_uuid, (string) $pusat->registration_url);
|
||||
$this->assertNotNull($pusat->qr_code_path);
|
||||
Storage::disk('local')->assertExists($pusat->qr_code_path);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.setup.saluran.store'), [
|
||||
'election_id' => $election->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'number' => '3',
|
||||
'voter_count' => 500,
|
||||
])
|
||||
->assertRedirect(route('admin.setup.saluran.index'));
|
||||
|
||||
$this->assertDatabaseHas('saluran_mengundis', [
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'number' => '1',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_admin_can_create_position_quota_and_assign_ppm(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$election = Election::query()->where('code', 'PRN2026')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$saluran = SaluranMengundi::query()->where('pusat_mengundi_id', $pusat->id)->firstOrFail();
|
||||
$position = Position::query()->where('code', 'KP')->firstOrFail();
|
||||
$newPpm = User::factory()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.setup.quotas.store'), [
|
||||
'election_id' => $election->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'saluran_mengundi_id' => $saluran->id,
|
||||
'position_id' => $position->id,
|
||||
'quota' => 6,
|
||||
])
|
||||
->assertRedirect(route('admin.setup.quotas.index'));
|
||||
|
||||
$quota = PositionQuota::query()
|
||||
->where('election_id', $election->id)
|
||||
->where('pusat_mengundi_id', $pusat->id)
|
||||
->where('saluran_mengundi_id', $saluran->id)
|
||||
->where('position_id', $position->id)
|
||||
->firstOrFail();
|
||||
|
||||
$this->assertSame(6, $quota->quota);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.setup.pusat.assign-ppm', $pusat), [
|
||||
'user_id' => $newPpm->id,
|
||||
])
|
||||
->assertRedirect(route('admin.setup.pusat.show', $pusat));
|
||||
|
||||
$newPpm->refresh();
|
||||
$this->assertTrue($newPpm->hasRole('PPM'));
|
||||
|
||||
$ppmPosition = Position::query()->where('code', 'PPM')->firstOrFail();
|
||||
|
||||
$this->assertTrue(StaffAssignment::query()
|
||||
->where('election_id', $election->id)
|
||||
->where('pusat_mengundi_id', $pusat->id)
|
||||
->where('position_id', $ppmPosition->id)
|
||||
->where('user_id', $newPpm->id)
|
||||
->where('status', 'active')
|
||||
->exists());
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_access_admin_setup_routes(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail();
|
||||
|
||||
$this->actingAs($ktm)
|
||||
->get(route('admin.setup.index'))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_public_qr_placeholder_uses_public_uuid_without_authentication(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
|
||||
$this->get(route('public.applications.create', [
|
||||
'pusatMengundi' => $pusat->public_uuid,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSee($pusat->name)
|
||||
->assertSee($pusat->code);
|
||||
}
|
||||
}
|
||||
175
tests/Feature/AttendanceModuleTest.php
Normal file
175
tests/Feature/AttendanceModuleTest.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\DaerahMengundi;
|
||||
use App\Models\Election;
|
||||
use App\Models\ExportLog;
|
||||
use App\Models\Position;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AttendanceModuleTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_ppm_can_record_attendance_for_own_pusat(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$this->activateAttendance($pusat->election);
|
||||
$assignment = StaffAssignment::query()
|
||||
->where('pusat_mengundi_id', $pusat->id)
|
||||
->where('status', 'active')
|
||||
->firstOrFail();
|
||||
|
||||
$this->actingAs($ppm)
|
||||
->post(route('ppm.attendance.store', $pusat), [
|
||||
'attendance' => [
|
||||
$assignment->id => [
|
||||
'status' => 'present',
|
||||
'check_in_time' => now()->format('Y-m-d H:i:s'),
|
||||
'note' => 'Hadir awal.',
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertRedirect(route('ppm.attendance.show', $pusat));
|
||||
|
||||
$this->assertDatabaseHas('attendances', [
|
||||
'staff_assignment_id' => $assignment->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'status' => 'present',
|
||||
'recorded_by_user_id' => $ppm->id,
|
||||
'note' => 'Hadir awal.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_ppm_cannot_record_attendance_for_other_pusat(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
$otherPusat = $this->otherPusat();
|
||||
$this->activateAttendance($otherPusat->election);
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
$assignment = StaffAssignment::query()->create([
|
||||
'election_id' => $otherPusat->election_id,
|
||||
'position_id' => $position->id,
|
||||
'pusat_mengundi_id' => $otherPusat->id,
|
||||
'status' => 'active',
|
||||
'assigned_at' => now(),
|
||||
'source' => 'test',
|
||||
]);
|
||||
|
||||
$this->actingAs($ppm)
|
||||
->post(route('ppm.attendance.store', $otherPusat), [
|
||||
'attendance' => [
|
||||
$assignment->id => ['status' => 'present'],
|
||||
],
|
||||
])
|
||||
->assertSessionHasErrors('pusat_mengundi_id');
|
||||
|
||||
$this->assertDatabaseMissing('attendances', [
|
||||
'staff_assignment_id' => $assignment->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_attendance_recording_requires_active_attendance_module(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$assignment = StaffAssignment::query()
|
||||
->where('pusat_mengundi_id', $pusat->id)
|
||||
->where('status', 'active')
|
||||
->firstOrFail();
|
||||
|
||||
$this->actingAs($ppm)
|
||||
->post(route('ppm.attendance.store', $pusat), [
|
||||
'attendance' => [
|
||||
$assignment->id => ['status' => 'present'],
|
||||
],
|
||||
])
|
||||
->assertSessionHasErrors('attendance');
|
||||
}
|
||||
|
||||
public function test_admin_can_view_attendance_dashboard_and_detail(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$this->activateAttendance($pusat->election);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.attendance.index'))
|
||||
->assertOk()
|
||||
->assertSee($pusat->name);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.attendance.show', $pusat))
|
||||
->assertOk()
|
||||
->assertSee('Ringkasan Mengikut Jawatan');
|
||||
}
|
||||
|
||||
public function test_attendance_export_creates_export_log(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.attendance.export', $pusat))
|
||||
->assertOk();
|
||||
|
||||
$exportLog = ExportLog::query()->where('report_type', 'attendance_detail_by_pusat')->firstOrFail();
|
||||
|
||||
$this->assertSame($admin->id, $exportLog->generated_by_user_id);
|
||||
$this->assertSame($pusat->election_id, $exportLog->election_id);
|
||||
Storage::disk('local')->assertExists($exportLog->path);
|
||||
}
|
||||
|
||||
public function test_admin_kewangan_cannot_access_attendance_dashboard(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail();
|
||||
|
||||
$this->actingAs($finance)
|
||||
->get(route('admin.attendance.index'))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
private function activateAttendance(Election $election): void
|
||||
{
|
||||
$election->settings()->update([
|
||||
'is_attendance_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function otherPusat(): PusatMengundi
|
||||
{
|
||||
$election = Election::query()->where('code', 'PRN2026')->firstOrFail();
|
||||
$daerah = DaerahMengundi::factory()->forElection($election)->create([
|
||||
'code' => 'D888',
|
||||
'name' => 'Daerah Attendance Lain',
|
||||
]);
|
||||
|
||||
return PusatMengundi::factory()->create([
|
||||
'election_id' => $election->id,
|
||||
'daerah_mengundi_id' => $daerah->id,
|
||||
'code' => 'PM888',
|
||||
'name' => 'Pusat Attendance Lain',
|
||||
]);
|
||||
}
|
||||
}
|
||||
54
tests/Feature/Auth/AuthenticationTest.php
Normal file
54
tests/Feature/Auth/AuthenticationTest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthenticationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_login_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/login');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_users_can_authenticate_using_the_login_screen(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
public function test_users_can_not_authenticate_with_invalid_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_users_can_logout(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/logout');
|
||||
|
||||
$this->assertGuest();
|
||||
$response->assertRedirect('/');
|
||||
}
|
||||
}
|
||||
58
tests/Feature/Auth/EmailVerificationTest.php
Normal file
58
tests/Feature/Auth/EmailVerificationTest.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmailVerificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_email_verification_screen_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/verify-email');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_email_can_be_verified(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
Event::fake();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
Event::assertDispatched(Verified::class);
|
||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
public function test_email_is_not_verified_with_invalid_hash(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1('wrong-email')]
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
}
|
||||
}
|
||||
44
tests/Feature/Auth/PasswordConfirmationTest.php
Normal file
44
tests/Feature/Auth/PasswordConfirmationTest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordConfirmationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_confirm_password_screen_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/confirm-password');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_password_can_be_confirmed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
public function test_password_is_not_confirmed_with_invalid_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors();
|
||||
}
|
||||
}
|
||||
73
tests/Feature/Auth/PasswordResetTest.php
Normal file
73
tests/Feature/Auth/PasswordResetTest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordResetTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_reset_password_link_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/forgot-password');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_reset_password_link_can_be_requested(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class);
|
||||
}
|
||||
|
||||
public function test_reset_password_screen_can_be_rendered(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
|
||||
$response = $this->get('/reset-password/'.$notification->token);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_password_can_be_reset_with_valid_token(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
||||
$response = $this->post('/reset-password', [
|
||||
'token' => $notification->token,
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
51
tests/Feature/Auth/PasswordUpdateTest.php
Normal file
51
tests/Feature/Auth/PasswordUpdateTest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordUpdateTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_password_can_be_updated(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'new-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
|
||||
}
|
||||
|
||||
public function test_correct_password_must_be_provided_to_update_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'wrong-password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'new-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasErrorsIn('updatePassword', 'current_password')
|
||||
->assertRedirect('/profile');
|
||||
}
|
||||
}
|
||||
31
tests/Feature/Auth/RegistrationTest.php
Normal file
31
tests/Feature/Auth/RegistrationTest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RegistrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_public_account_registration_screen_is_disabled(): void
|
||||
{
|
||||
$response = $this->get('/register');
|
||||
|
||||
$response->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_public_account_registration_post_is_disabled(): void
|
||||
{
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertGuest();
|
||||
$response->assertNotFound();
|
||||
}
|
||||
}
|
||||
66
tests/Feature/CoreDomainSeederTest.php
Normal file
66
tests/Feature/CoreDomainSeederTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Election;
|
||||
use App\Models\Position;
|
||||
use App\Models\PositionQuota;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CoreDomainSeederTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_core_domain_seeders_create_sample_election_hierarchy_and_dual_role_assignment(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$this->assertDatabaseCount('positions', 9);
|
||||
$this->assertDatabaseCount('elections', 1);
|
||||
$this->assertDatabaseCount('bahagian_pilihanrayas', 1);
|
||||
$this->assertDatabaseCount('daerah_mengundis', 1);
|
||||
$this->assertDatabaseCount('pusat_mengundis', 1);
|
||||
$this->assertDatabaseCount('saluran_mengundis', 3);
|
||||
$this->assertDatabaseCount('position_quotas', 17);
|
||||
$this->assertDatabaseCount('staff_assignments', 3);
|
||||
|
||||
$election = Election::query()->where('code', 'PRN2026')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
|
||||
$this->assertTrue($ppm->hasRole('PPM'));
|
||||
$this->assertTrue($ppm->hasRole('KTM'));
|
||||
$this->assertSame($election->id, $pusat->election_id);
|
||||
$this->assertSame(3, $pusat->saluranMengundis()->count());
|
||||
$this->assertNotNull($pusat->wheelchairAllocation);
|
||||
|
||||
$ppmPosition = Position::query()->where('code', 'PPM')->firstOrFail();
|
||||
$ktmPosition = Position::query()->where('code', 'KTM')->firstOrFail();
|
||||
|
||||
$this->assertTrue(PositionQuota::query()
|
||||
->where('election_id', $election->id)
|
||||
->where('pusat_mengundi_id', $pusat->id)
|
||||
->where('position_id', $ppmPosition->id)
|
||||
->whereNull('saluran_mengundi_id')
|
||||
->exists());
|
||||
|
||||
$this->assertTrue(StaffAssignment::query()
|
||||
->where('election_id', $election->id)
|
||||
->where('user_id', $ppm->id)
|
||||
->where('position_id', $ppmPosition->id)
|
||||
->whereNull('saluran_mengundi_id')
|
||||
->exists());
|
||||
|
||||
$this->assertTrue(StaffAssignment::query()
|
||||
->where('election_id', $election->id)
|
||||
->where('user_id', $ppm->id)
|
||||
->where('position_id', $ktmPosition->id)
|
||||
->whereNotNull('saluran_mengundi_id')
|
||||
->exists());
|
||||
}
|
||||
}
|
||||
38
tests/Feature/DashboardAccessTest.php
Normal file
38
tests/Feature/DashboardAccessTest.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DashboardAccessTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_dashboard_redirects_admin_to_admin_dashboard(): void
|
||||
{
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Admin');
|
||||
|
||||
$response = $this->actingAs($admin)->get('/dashboard');
|
||||
|
||||
$response->assertRedirect(route('admin.dashboard', absolute: false));
|
||||
}
|
||||
|
||||
public function test_role_dashboard_is_protected_by_role_middleware(): void
|
||||
{
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Admin');
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($admin)->get('/admin/dashboard')->assertOk();
|
||||
$this->actingAs($user)->get('/admin/dashboard')->assertForbidden();
|
||||
}
|
||||
}
|
||||
211
tests/Feature/DocumentDownloadAuthorizationTest.php
Normal file
211
tests/Feature/DocumentDownloadAuthorizationTest.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDocument;
|
||||
use App\Models\Position;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DocumentDownloadAuthorizationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ── Admin path ──────────────────────────────────────────────────────
|
||||
|
||||
public function test_admin_can_download_ic_document(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.documents.download', $this->document('ic_document')))
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_admin_can_download_bank_statement(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('admin.documents.download', $this->document('bank_statement')))
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_unauthorized_role_cannot_access_admin_download_route(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail();
|
||||
|
||||
$this->actingAs($ktm)
|
||||
->get(route('admin.documents.download', $this->document('bank_statement')))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
// ── Finance path ─────────────────────────────────────────────────────
|
||||
|
||||
public function test_finance_can_download_bank_statement(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail();
|
||||
|
||||
$this->actingAs($finance)
|
||||
->get(route('kewangan.documents.download', $this->document('bank_statement')))
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_finance_cannot_download_ic_document_via_finance_route(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail();
|
||||
|
||||
$this->actingAs($finance)
|
||||
->get(route('kewangan.documents.download', $this->document('ic_document')))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
// ── PPM path ──────────────────────────────────────────────────────────
|
||||
|
||||
public function test_ppm_can_download_ic_document_from_own_pusat(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
$pusat = $this->ppmPusat($ppm);
|
||||
$document = $this->documentForPusat($pusat, 'ic_document');
|
||||
|
||||
$this->actingAs($ppm)
|
||||
->get(route('ppm.applications.documents.download', [
|
||||
'application' => $document->application->public_uuid,
|
||||
'document' => $document,
|
||||
]))
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_ppm_can_download_bank_statement_from_own_pusat(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
$pusat = $this->ppmPusat($ppm);
|
||||
$document = $this->documentForPusat($pusat, 'bank_statement');
|
||||
|
||||
$this->actingAs($ppm)
|
||||
->get(route('ppm.applications.documents.download', [
|
||||
'application' => $document->application->public_uuid,
|
||||
'document' => $document,
|
||||
]))
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_ppm_cannot_download_document_from_another_pusat(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
|
||||
// Create a document belonging to a DIFFERENT pusat than PPM's assignment
|
||||
$otherPusat = PusatMengundi::factory()->create([
|
||||
'election_id' => PusatMengundi::query()->where('code', 'PM001')->value('election_id'),
|
||||
'daerah_mengundi_id' => PusatMengundi::query()->where('code', 'PM001')->value('daerah_mengundi_id'),
|
||||
]);
|
||||
$document = $this->documentForPusat($otherPusat, 'ic_document');
|
||||
|
||||
$this->actingAs($ppm)
|
||||
->get(route('ppm.applications.documents.download', [
|
||||
'application' => $document->application->public_uuid,
|
||||
'document' => $document,
|
||||
]))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_ppm_download_records_activity_log(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
$pusat = $this->ppmPusat($ppm);
|
||||
$document = $this->documentForPusat($pusat, 'ic_document');
|
||||
|
||||
$this->actingAs($ppm)
|
||||
->get(route('ppm.applications.documents.download', [
|
||||
'application' => $document->application->public_uuid,
|
||||
'document' => $document,
|
||||
]));
|
||||
|
||||
$this->assertDatabaseHas('activity_log', [
|
||||
'log_name' => 'documents',
|
||||
'description' => 'document_downloaded_by_ppm',
|
||||
'causer_id' => $ppm->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private function document(string $type, string $fileName = 'file.pdf'): ApplicationDocument
|
||||
{
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
|
||||
return $this->documentForPusat($pusat, $type, $fileName);
|
||||
}
|
||||
|
||||
private function documentForPusat(PusatMengundi $pusat, string $type, string $fileName = 'file.pdf'): ApplicationDocument
|
||||
{
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
$application = Application::factory()->create([
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'requested_position_id' => $position->id,
|
||||
]);
|
||||
$path = 'applications/'.$application->public_uuid.'/'.$fileName;
|
||||
Storage::disk('local')->put($path, 'test content');
|
||||
|
||||
$document = ApplicationDocument::query()->create([
|
||||
'application_id' => $application->id,
|
||||
'document_type' => $type,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => $fileName,
|
||||
'mime_type' => 'application/pdf',
|
||||
'size' => 12,
|
||||
]);
|
||||
|
||||
$document->setRelation('application', $application);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
private function ppmPusat(User $ppm): PusatMengundi
|
||||
{
|
||||
$ppmPosition = Position::query()->where('code', 'PPM')->firstOrFail();
|
||||
|
||||
$assignment = StaffAssignment::query()
|
||||
->where('user_id', $ppm->id)
|
||||
->where('position_id', $ppmPosition->id)
|
||||
->where('status', 'active')
|
||||
->firstOrFail();
|
||||
|
||||
return PusatMengundi::query()->findOrFail($assignment->pusat_mengundi_id);
|
||||
}
|
||||
}
|
||||
19
tests/Feature/ExampleTest.php
Normal file
19
tests/Feature/ExampleTest.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
||||
180
tests/Feature/ExportPurgeCommandTest.php
Normal file
180
tests/Feature/ExportPurgeCommandTest.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ExportLog;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExportPurgeCommandTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_command_purges_files_older_than_retention_period(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$path = 'exports/finance/old-export.xlsx';
|
||||
Storage::disk('local')->put($path, 'export content');
|
||||
|
||||
$log = ExportLog::query()->create([
|
||||
'generated_at' => now()->subDays(31),
|
||||
'report_type' => 'finance_verification_list',
|
||||
'file_name' => 'old-export.xlsx',
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'created_at' => now()->subDays(31),
|
||||
'updated_at' => now()->subDays(31),
|
||||
]);
|
||||
|
||||
$this->artisan('exports:purge', ['--days' => 30])
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('Purged 1 export file(s).');
|
||||
|
||||
Storage::disk('local')->assertMissing($path);
|
||||
$this->assertNotNull($log->fresh()->purged_at);
|
||||
}
|
||||
|
||||
public function test_command_does_not_purge_files_within_retention_period(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$path = 'exports/finance/recent-export.xlsx';
|
||||
Storage::disk('local')->put($path, 'export content');
|
||||
|
||||
$log = ExportLog::query()->create([
|
||||
'generated_at' => now()->subDays(10),
|
||||
'report_type' => 'finance_verification_list',
|
||||
'file_name' => 'recent-export.xlsx',
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
]);
|
||||
|
||||
$this->artisan('exports:purge', ['--days' => 30])
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('No export files eligible');
|
||||
|
||||
Storage::disk('local')->assertExists($path);
|
||||
$this->assertNull($log->fresh()->purged_at);
|
||||
}
|
||||
|
||||
public function test_command_marks_log_as_purged_even_when_file_missing(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$log = ExportLog::query()->create([
|
||||
'generated_at' => now()->subDays(40),
|
||||
'report_type' => 'attendance_detail_by_pusat',
|
||||
'file_name' => 'missing-file.xlsx',
|
||||
'disk' => 'local',
|
||||
'path' => 'exports/attendance/missing-file.xlsx',
|
||||
'created_at' => now()->subDays(40),
|
||||
'updated_at' => now()->subDays(40),
|
||||
]);
|
||||
|
||||
$this->artisan('exports:purge', ['--days' => 30])
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('1 record(s) had no file on disk');
|
||||
|
||||
$this->assertNotNull($log->fresh()->purged_at);
|
||||
}
|
||||
|
||||
public function test_command_does_not_re_purge_already_purged_records(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$path = 'exports/finance/already-purged.xlsx';
|
||||
|
||||
$log = ExportLog::query()->create([
|
||||
'generated_at' => now()->subDays(60),
|
||||
'report_type' => 'finance_verification_list',
|
||||
'file_name' => 'already-purged.xlsx',
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'purged_at' => now()->subDays(5),
|
||||
'created_at' => now()->subDays(60),
|
||||
'updated_at' => now()->subDays(60),
|
||||
]);
|
||||
|
||||
$this->artisan('exports:purge', ['--days' => 30])
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('No export files eligible');
|
||||
|
||||
// purged_at should not change
|
||||
$this->assertEquals(
|
||||
$log->fresh()->purged_at->toDateString(),
|
||||
now()->subDays(5)->toDateString()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_command_accepts_custom_retention_days(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$path = 'exports/finance/week-old.xlsx';
|
||||
Storage::disk('local')->put($path, 'export content');
|
||||
|
||||
$log = ExportLog::query()->create([
|
||||
'generated_at' => now()->subDays(8),
|
||||
'report_type' => 'finance_verification_list',
|
||||
'file_name' => 'week-old.xlsx',
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'created_at' => now()->subDays(8),
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
// With default 30 days — should NOT purge
|
||||
$this->artisan('exports:purge', ['--days' => 30])
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('No export files eligible');
|
||||
|
||||
$this->assertNull($log->fresh()->purged_at);
|
||||
|
||||
// With 7 days — should purge
|
||||
$this->artisan('exports:purge', ['--days' => 7])
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('Purged 1 export file(s).');
|
||||
|
||||
$this->assertNotNull($log->fresh()->purged_at);
|
||||
Storage::disk('local')->assertMissing($path);
|
||||
}
|
||||
|
||||
public function test_command_records_activity_log_on_purge(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$path = 'exports/finance/log-test.xlsx';
|
||||
Storage::disk('local')->put($path, 'export content');
|
||||
|
||||
ExportLog::query()->create([
|
||||
'generated_at' => now()->subDays(31),
|
||||
'report_type' => 'finance_verification_list',
|
||||
'file_name' => 'log-test.xlsx',
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'created_at' => now()->subDays(31),
|
||||
'updated_at' => now()->subDays(31),
|
||||
]);
|
||||
|
||||
$this->artisan('exports:purge', ['--days' => 30])->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseHas('activity_log', [
|
||||
'log_name' => 'exports',
|
||||
'description' => 'export_file_purged',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_command_rejects_invalid_days_option(): void
|
||||
{
|
||||
$this->artisan('exports:purge', ['--days' => 0])
|
||||
->assertFailed();
|
||||
}
|
||||
}
|
||||
155
tests/Feature/FinanceModuleTest.php
Normal file
155
tests/Feature/FinanceModuleTest.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDocument;
|
||||
use App\Models\BankVerification;
|
||||
use App\Models\ExportLog;
|
||||
use App\Models\Position;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FinanceModuleTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_admin_kewangan_can_view_approved_staff_bank_list(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail();
|
||||
$application = $this->assignedApplication('Finance View', '910101105551');
|
||||
|
||||
$this->actingAs($finance)
|
||||
->get(route('kewangan.bank.index'))
|
||||
->assertOk()
|
||||
->assertSee($application->name)
|
||||
->assertSee('Penyata ada');
|
||||
}
|
||||
|
||||
public function test_admin_kewangan_can_verify_bank_account(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail();
|
||||
$application = $this->assignedApplication('Finance Verify', '910101105552');
|
||||
$verification = $application->bankVerification()->firstOrFail();
|
||||
|
||||
$this->actingAs($finance)
|
||||
->patch(route('kewangan.bank.update', $verification), [
|
||||
'status' => 'verified',
|
||||
'finance_note' => 'Akaun disahkan.',
|
||||
])
|
||||
->assertRedirect(route('kewangan.bank.show', $verification));
|
||||
|
||||
$verification->refresh();
|
||||
|
||||
$this->assertSame('verified', $verification->status);
|
||||
$this->assertSame('Akaun disahkan.', $verification->finance_note);
|
||||
$this->assertSame($finance->id, $verification->verified_by_user_id);
|
||||
$this->assertNotNull($verification->verified_at);
|
||||
}
|
||||
|
||||
public function test_finance_filters_missing_bank_statement_and_account_number(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail();
|
||||
$complete = $this->assignedApplication('Finance Complete', '910101105553');
|
||||
$missing = $this->assignedApplication('Finance Missing', '910101105554', false, false);
|
||||
|
||||
$this->actingAs($finance)
|
||||
->get(route('kewangan.bank.index', [
|
||||
'missing_bank_statement' => 1,
|
||||
'missing_account_number' => 1,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSee($missing->name)
|
||||
->assertDontSee($complete->name);
|
||||
}
|
||||
|
||||
public function test_admin_kewangan_cannot_change_assignment(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail();
|
||||
$assignment = StaffAssignment::query()->firstOrFail();
|
||||
|
||||
$this->actingAs($finance)
|
||||
->get(route('admin.management.assignments.edit', $assignment))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_finance_export_creates_export_log(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail();
|
||||
$this->assignedApplication('Finance Export', '910101105555');
|
||||
|
||||
$this->actingAs($finance)
|
||||
->get(route('kewangan.bank.export'))
|
||||
->assertOk();
|
||||
|
||||
$exportLog = ExportLog::query()->where('report_type', 'finance_verification_list')->firstOrFail();
|
||||
|
||||
$this->assertSame($finance->id, $exportLog->generated_by_user_id);
|
||||
$this->assertNotNull($exportLog->generated_at);
|
||||
$this->assertSame('local', $exportLog->disk);
|
||||
Storage::disk('local')->assertExists($exportLog->path);
|
||||
}
|
||||
|
||||
private function assignedApplication(string $name, string $icNumber, bool $withBankStatement = true, bool $withAccountNumber = true): Application
|
||||
{
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'requested_position_id' => $position->id,
|
||||
'approved_position_id' => $position->id,
|
||||
'status' => 'assigned',
|
||||
'name' => $name,
|
||||
'ic_number' => $icNumber,
|
||||
'bank_name' => 'Maybank',
|
||||
'bank_account_number' => $withAccountNumber ? '1234567890' : null,
|
||||
]);
|
||||
|
||||
BankVerification::query()->create([
|
||||
'application_id' => $application->id,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
StaffAssignment::query()->create([
|
||||
'election_id' => $pusat->election_id,
|
||||
'application_id' => $application->id,
|
||||
'position_id' => $position->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'status' => 'active',
|
||||
'assigned_at' => now(),
|
||||
'source' => 'test',
|
||||
]);
|
||||
|
||||
if ($withBankStatement) {
|
||||
ApplicationDocument::query()->create([
|
||||
'application_id' => $application->id,
|
||||
'document_type' => 'bank_statement',
|
||||
'disk' => 'local',
|
||||
'path' => 'applications/'.$application->public_uuid.'/bank.pdf',
|
||||
'original_name' => 'bank.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size' => 12,
|
||||
]);
|
||||
}
|
||||
|
||||
return $application;
|
||||
}
|
||||
}
|
||||
204
tests/Feature/KtmFlowTest.php
Normal file
204
tests/Feature/KtmFlowTest.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Mail\KtmCreatedApplicantMail;
|
||||
use App\Models\Application;
|
||||
use App\Models\Position;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class KtmFlowTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_ktm_can_view_assigned_saluran_and_kp_team(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail();
|
||||
$assignment = $this->ktmAssignmentFor($ktm);
|
||||
|
||||
$this->actingAs($ktm)
|
||||
->get(route('ktm.applications.index'))
|
||||
->assertOk()
|
||||
->assertSee('Saluran '.$assignment->saluranMengundi?->number)
|
||||
->assertSee('Team KP');
|
||||
}
|
||||
|
||||
public function test_ktm_can_register_kp_only_and_email_applicant(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail();
|
||||
$assignment = $this->ktmAssignmentFor($ktm);
|
||||
$kpPosition = Position::query()->where('code', 'KP')->firstOrFail();
|
||||
|
||||
$this->actingAs($ktm)
|
||||
->post(route('ktm.applications.store'), [
|
||||
'ktm_assignment_id' => $assignment->id,
|
||||
'name' => 'KP Didaftar KTM',
|
||||
'ic_number' => '880101101111',
|
||||
'phone_number' => '0123456789',
|
||||
'email' => 'kp-created@example.test',
|
||||
'address' => 'Alamat KP',
|
||||
'requested_position_code' => 'KTM',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$application = Application::query()->where('ic_number', '880101101111')->firstOrFail();
|
||||
|
||||
$this->assertSame('created_by_ktm', $application->source);
|
||||
$this->assertSame('submitted', $application->status);
|
||||
$this->assertSame($kpPosition->id, $application->requested_position_id);
|
||||
$this->assertSame($assignment->id, $application->selected_ktm_assignment_id);
|
||||
|
||||
Mail::assertQueued(KtmCreatedApplicantMail::class, fn (KtmCreatedApplicantMail $mail): bool => $mail->application->is($application));
|
||||
}
|
||||
|
||||
public function test_ktm_cannot_register_kp_after_registration_period(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail();
|
||||
$assignment = $this->ktmAssignmentFor($ktm);
|
||||
$assignment->election->settings()->update([
|
||||
'registration_start_date' => now()->subWeeks(4)->toDateString(),
|
||||
'registration_end_date' => now()->subDay()->toDateString(),
|
||||
'is_registration_open' => true,
|
||||
'is_registration_open_override' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($ktm)
|
||||
->post(route('ktm.applications.store'), [
|
||||
'ktm_assignment_id' => $assignment->id,
|
||||
'name' => 'KP Lewat',
|
||||
'ic_number' => '880101101112',
|
||||
'phone_number' => '0123456789',
|
||||
'email' => 'kp-late@example.test',
|
||||
])
|
||||
->assertSessionHasErrors('registration');
|
||||
|
||||
$this->assertDatabaseMissing('applications', [
|
||||
'ic_number' => '880101101112',
|
||||
]);
|
||||
Mail::assertNothingQueued();
|
||||
}
|
||||
|
||||
public function test_ktm_can_approve_kp_application_under_own_team(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail();
|
||||
$assignment = $this->ktmAssignmentFor($ktm);
|
||||
$application = $this->kpApplicationFor($assignment, 'KP Untuk Approve', '880101101113');
|
||||
|
||||
$this->actingAs($ktm)
|
||||
->post(route('ktm.applications.approve', $application->public_uuid))
|
||||
->assertRedirect(route('ktm.applications.show', $application->public_uuid));
|
||||
|
||||
$application->refresh();
|
||||
$kpPosition = Position::query()->where('code', 'KP')->firstOrFail();
|
||||
|
||||
$this->assertSame('assigned', $application->status);
|
||||
$this->assertSame($kpPosition->id, $application->approved_position_id);
|
||||
$this->assertTrue(StaffAssignment::query()
|
||||
->where('application_id', $application->id)
|
||||
->where('reports_to_assignment_id', $assignment->id)
|
||||
->where('position_id', $kpPosition->id)
|
||||
->where('status', 'active')
|
||||
->exists());
|
||||
}
|
||||
|
||||
public function test_ktm_cannot_approve_non_kp_or_other_team_application(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail();
|
||||
$ownAssignment = $this->ktmAssignmentFor($ktm);
|
||||
$otherKtm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
$otherAssignment = $this->ktmAssignmentFor($otherKtm);
|
||||
$papmPosition = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
|
||||
$otherTeamApplication = $this->kpApplicationFor($otherAssignment, 'KP Team Lain', '880101101114');
|
||||
$nonKpApplication = Application::query()->create([
|
||||
'election_id' => $ownAssignment->election_id,
|
||||
'pusat_mengundi_id' => $ownAssignment->pusat_mengundi_id,
|
||||
'selected_ktm_assignment_id' => $ownAssignment->id,
|
||||
'public_uuid' => (string) Str::uuid(),
|
||||
'source' => 'public',
|
||||
'status' => 'submitted',
|
||||
'name' => 'Bukan KP',
|
||||
'ic_number' => '880101101115',
|
||||
'phone_number' => '0123456789',
|
||||
'email' => 'not-kp@example.test',
|
||||
'requested_position_id' => $papmPosition->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($ktm)
|
||||
->post(route('ktm.applications.approve', $otherTeamApplication->public_uuid))
|
||||
->assertSessionHasErrors('application');
|
||||
|
||||
$this->actingAs($ktm)
|
||||
->post(route('ktm.applications.approve', $nonKpApplication->public_uuid))
|
||||
->assertSessionHasErrors('requested_position_id');
|
||||
}
|
||||
|
||||
public function test_ktm_can_delete_own_created_applicant_to_allow_public_registration(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ktm = User::query()->where('email', 'ktm@prn.local')->firstOrFail();
|
||||
$assignment = $this->ktmAssignmentFor($ktm);
|
||||
$application = $this->kpApplicationFor($assignment, 'KP Padam', '880101101116', 'created_by_ktm');
|
||||
|
||||
$this->actingAs($ktm)
|
||||
->delete(route('ktm.applications.destroy', $application->public_uuid))
|
||||
->assertRedirect(route('ktm.applications.index'));
|
||||
|
||||
$this->assertSoftDeleted('applications', [
|
||||
'id' => $application->id,
|
||||
'status' => 'deleted_by_ktm',
|
||||
]);
|
||||
}
|
||||
|
||||
private function ktmAssignmentFor(User $user): StaffAssignment
|
||||
{
|
||||
$ktmPosition = Position::query()->where('code', 'KTM')->firstOrFail();
|
||||
|
||||
return StaffAssignment::query()
|
||||
->with(['election.settings', 'pusatMengundi', 'saluranMengundi'])
|
||||
->where('user_id', $user->id)
|
||||
->where('position_id', $ktmPosition->id)
|
||||
->where('status', 'active')
|
||||
->whereNotNull('saluran_mengundi_id')
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function kpApplicationFor(StaffAssignment $assignment, string $name, string $icNumber, string $source = 'public'): Application
|
||||
{
|
||||
$kpPosition = Position::query()->where('code', 'KP')->firstOrFail();
|
||||
|
||||
return Application::query()->create([
|
||||
'election_id' => $assignment->election_id,
|
||||
'pusat_mengundi_id' => $assignment->pusat_mengundi_id,
|
||||
'selected_ktm_assignment_id' => $assignment->id,
|
||||
'public_uuid' => (string) Str::uuid(),
|
||||
'source' => $source,
|
||||
'status' => 'submitted',
|
||||
'name' => $name,
|
||||
'ic_number' => $icNumber,
|
||||
'phone_number' => '0123456789',
|
||||
'email' => Str::slug($name).'@example.test',
|
||||
'requested_position_id' => $kpPosition->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
246
tests/Feature/PpmApplicationReviewTest.php
Normal file
246
tests/Feature/PpmApplicationReviewTest.php
Normal file
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDocument;
|
||||
use App\Models\DaerahMengundi;
|
||||
use App\Models\Election;
|
||||
use App\Models\Position;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\SaluranMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PpmApplicationReviewTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_ppm_can_view_only_own_pusat_applications_in_list(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
$ownPusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$otherPusat = $this->otherPusat();
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
|
||||
$ownApplication = Application::factory()->create([
|
||||
'election_id' => $ownPusat->election_id,
|
||||
'pusat_mengundi_id' => $ownPusat->id,
|
||||
'requested_position_id' => $position->id,
|
||||
'name' => 'Pemohon Pusat Sendiri',
|
||||
]);
|
||||
|
||||
Application::factory()->create([
|
||||
'election_id' => $otherPusat->election_id,
|
||||
'pusat_mengundi_id' => $otherPusat->id,
|
||||
'requested_position_id' => $position->id,
|
||||
'name' => 'Pemohon Pusat Lain',
|
||||
]);
|
||||
|
||||
$this->actingAs($ppm)
|
||||
->get(route('ppm.applications.index'))
|
||||
->assertOk()
|
||||
->assertSee($ownApplication->name)
|
||||
->assertDontSee('Pemohon Pusat Lain');
|
||||
}
|
||||
|
||||
public function test_ppm_cannot_view_application_outside_own_pusat(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
$otherPusat = $this->otherPusat();
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'election_id' => $otherPusat->election_id,
|
||||
'pusat_mengundi_id' => $otherPusat->id,
|
||||
'requested_position_id' => $position->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($ppm)
|
||||
->get(route('ppm.applications.show', $application->public_uuid))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_ppm_cannot_approve_application_without_required_documents(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'requested_position_id' => $position->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($ppm)
|
||||
->post(route('ppm.applications.approve', $application->public_uuid), [
|
||||
'approved_position_code' => 'PAPM',
|
||||
])
|
||||
->assertSessionHasErrors('documents');
|
||||
|
||||
$this->assertDatabaseMissing('staff_assignments', [
|
||||
'application_id' => $application->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_ppm_can_approve_ktm_and_assign_saluran(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'KTM')->firstOrFail();
|
||||
$saluran = SaluranMengundi::query()
|
||||
->where('pusat_mengundi_id', $pusat->id)
|
||||
->where('number', '3')
|
||||
->firstOrFail();
|
||||
$application = $this->applicationWithDocuments($pusat, $position, 'Pemohon KTM');
|
||||
|
||||
$this->actingAs($ppm)
|
||||
->post(route('ppm.applications.approve', $application->public_uuid), [
|
||||
'approved_position_code' => 'KTM',
|
||||
'saluran_mengundi_id' => $saluran->id,
|
||||
'note' => 'Diluluskan untuk Saluran 3.',
|
||||
])
|
||||
->assertRedirect(route('ppm.applications.show', $application->public_uuid));
|
||||
|
||||
$application->refresh();
|
||||
|
||||
$this->assertSame('assigned', $application->status);
|
||||
$this->assertSame($position->id, $application->approved_position_id);
|
||||
$this->assertTrue(StaffAssignment::query()
|
||||
->where('application_id', $application->id)
|
||||
->where('position_id', $position->id)
|
||||
->where('saluran_mengundi_id', $saluran->id)
|
||||
->where('status', 'active')
|
||||
->exists());
|
||||
$this->assertTrue($application->statusHistories()->where('to_status', 'assigned')->exists());
|
||||
}
|
||||
|
||||
public function test_ppm_can_change_role_and_assign_kp_to_approved_ktm(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$requestedPosition = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
$approvedPosition = Position::query()->where('code', 'KP')->firstOrFail();
|
||||
$ktmAssignment = $this->ktmAssignment($pusat);
|
||||
$application = $this->applicationWithDocuments($pusat, $requestedPosition, 'Pemohon Ditukar KP');
|
||||
|
||||
$this->actingAs($ppm)
|
||||
->post(route('ppm.applications.approve', $application->public_uuid), [
|
||||
'approved_position_code' => 'KP',
|
||||
'ktm_assignment_id' => $ktmAssignment->id,
|
||||
'note' => 'Tukar dari PAPM ke KP.',
|
||||
])
|
||||
->assertRedirect(route('ppm.applications.show', $application->public_uuid));
|
||||
|
||||
$application->refresh();
|
||||
|
||||
$this->assertSame('assigned', $application->status);
|
||||
$this->assertSame($approvedPosition->id, $application->approved_position_id);
|
||||
$this->assertSame($ktmAssignment->id, $application->selected_ktm_assignment_id);
|
||||
$this->assertTrue(StaffAssignment::query()
|
||||
->where('application_id', $application->id)
|
||||
->where('position_id', $approvedPosition->id)
|
||||
->where('reports_to_assignment_id', $ktmAssignment->id)
|
||||
->where('status', 'active')
|
||||
->exists());
|
||||
}
|
||||
|
||||
public function test_ppm_can_reject_own_pusat_application(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$ppm = User::query()->where('email', 'ppm@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
$application = Application::factory()->create([
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'requested_position_id' => $position->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($ppm)
|
||||
->post(route('ppm.applications.reject', $application->public_uuid), [
|
||||
'reason' => 'Tidak memenuhi syarat.',
|
||||
])
|
||||
->assertRedirect(route('ppm.applications.show', $application->public_uuid));
|
||||
|
||||
$application->refresh();
|
||||
|
||||
$this->assertSame('rejected', $application->status);
|
||||
$this->assertSame('Tidak memenuhi syarat.', $application->rejection_reason);
|
||||
$this->assertTrue($application->statusHistories()->where('to_status', 'rejected')->exists());
|
||||
}
|
||||
|
||||
private function otherPusat(): PusatMengundi
|
||||
{
|
||||
$election = Election::query()->where('code', 'PRN2026')->firstOrFail();
|
||||
$daerah = DaerahMengundi::factory()->forElection($election)->create([
|
||||
'code' => 'D999',
|
||||
'name' => 'Daerah Lain',
|
||||
]);
|
||||
|
||||
return PusatMengundi::factory()->create([
|
||||
'election_id' => $election->id,
|
||||
'daerah_mengundi_id' => $daerah->id,
|
||||
'code' => 'PM999',
|
||||
'name' => 'Pusat Lain',
|
||||
]);
|
||||
}
|
||||
|
||||
private function applicationWithDocuments(PusatMengundi $pusatMengundi, Position $position, string $name): Application
|
||||
{
|
||||
$application = Application::factory()->create([
|
||||
'election_id' => $pusatMengundi->election_id,
|
||||
'pusat_mengundi_id' => $pusatMengundi->id,
|
||||
'requested_position_id' => $position->id,
|
||||
'name' => $name,
|
||||
]);
|
||||
|
||||
foreach (['ic_document', 'bank_statement'] as $type) {
|
||||
$path = 'applications/'.$application->public_uuid.'/'.$type.'.pdf';
|
||||
Storage::disk('local')->put($path, 'test');
|
||||
|
||||
ApplicationDocument::query()->create([
|
||||
'application_id' => $application->id,
|
||||
'document_type' => $type,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => $type.'.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size' => 4,
|
||||
]);
|
||||
}
|
||||
|
||||
return $application;
|
||||
}
|
||||
|
||||
private function ktmAssignment(PusatMengundi $pusatMengundi): StaffAssignment
|
||||
{
|
||||
$ktmPosition = Position::query()->where('code', 'KTM')->firstOrFail();
|
||||
|
||||
return StaffAssignment::query()
|
||||
->where('election_id', $pusatMengundi->election_id)
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->where('position_id', $ktmPosition->id)
|
||||
->where('status', 'active')
|
||||
->whereNotNull('saluran_mengundi_id')
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
99
tests/Feature/ProfileTest.php
Normal file
99
tests/Feature/ProfileTest.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProfileTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_profile_page_is_displayed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->get('/profile');
|
||||
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
public function test_profile_information_can_be_updated(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch('/profile', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$user->refresh();
|
||||
|
||||
$this->assertSame('Test User', $user->name);
|
||||
$this->assertSame('test@example.com', $user->email);
|
||||
$this->assertNull($user->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch('/profile', [
|
||||
'name' => 'Test User',
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertNotNull($user->refresh()->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_user_can_delete_their_account(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->delete('/profile', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/');
|
||||
|
||||
$this->assertGuest();
|
||||
$this->assertNull($user->fresh());
|
||||
}
|
||||
|
||||
public function test_correct_password_must_be_provided_to_delete_account(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->delete('/profile', [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasErrorsIn('userDeletion', 'password')
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertNotNull($user->fresh());
|
||||
}
|
||||
}
|
||||
144
tests/Feature/PublicApplicationStatusTest.php
Normal file
144
tests/Feature/PublicApplicationStatusTest.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Position;
|
||||
use App\Models\PusatMengundi;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PublicApplicationStatusTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_submitted_application_shows_correct_status(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$application = $this->application('submitted');
|
||||
|
||||
$this->get(route('public.applications.status', $application->public_uuid))
|
||||
->assertOk()
|
||||
->assertSee('Menunggu Semakan')
|
||||
->assertDontSee($application->name) // full name must not appear
|
||||
->assertDontSee($application->ic_number); // IC must not appear
|
||||
}
|
||||
|
||||
public function test_assigned_application_shows_placed_status(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$application = $this->application('assigned');
|
||||
|
||||
$this->get(route('public.applications.status', $application->public_uuid))
|
||||
->assertOk()
|
||||
->assertSee('Diluluskan');
|
||||
}
|
||||
|
||||
public function test_rejected_application_shows_rejection_reason(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$application = $this->application('rejected', [
|
||||
'rejection_reason' => 'Dokumen IC tidak jelas.',
|
||||
]);
|
||||
|
||||
$this->get(route('public.applications.status', $application->public_uuid))
|
||||
->assertOk()
|
||||
->assertSee('Ditolak')
|
||||
->assertSee('Dokumen IC tidak jelas.');
|
||||
}
|
||||
|
||||
public function test_unknown_uuid_returns_200_with_generic_message(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$this->get(route('public.applications.status', (string) Str::uuid()))
|
||||
->assertOk()
|
||||
->assertSee('Status tidak dapat dipaparkan');
|
||||
}
|
||||
|
||||
public function test_deleted_by_ktm_application_returns_generic_message(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$application = $this->application('deleted_by_ktm');
|
||||
$application->delete(); // soft-delete
|
||||
|
||||
$this->get(route('public.applications.status', $application->public_uuid))
|
||||
->assertOk()
|
||||
->assertSee('Status tidak dapat dipaparkan')
|
||||
->assertDontSee($application->name);
|
||||
}
|
||||
|
||||
public function test_soft_deleted_application_returns_generic_message(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$application = $this->application('cancelled');
|
||||
$application->delete();
|
||||
|
||||
$this->get(route('public.applications.status', $application->public_uuid))
|
||||
->assertOk()
|
||||
->assertSee('Status tidak dapat dipaparkan');
|
||||
}
|
||||
|
||||
public function test_name_is_masked_in_status_view(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$application = $this->application('submitted', ['name' => 'Ahmad bin Ali']);
|
||||
|
||||
$response = $this->get(route('public.applications.status', $application->public_uuid))
|
||||
->assertOk();
|
||||
|
||||
$response->assertSee('Ahm'); // first 3 chars shown
|
||||
$response->assertDontSee('Ahmad bin Ali'); // full name not shown
|
||||
}
|
||||
|
||||
public function test_status_page_does_not_require_authentication(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$application = $this->application('submitted');
|
||||
|
||||
// Access without actingAs — must succeed
|
||||
$this->get(route('public.applications.status', $application->public_uuid))
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_status_page_shows_link_to_check_status_on_success_page(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$application = $this->application('submitted');
|
||||
$expectedUrl = route('public.applications.status', $application->public_uuid);
|
||||
|
||||
$this->get(route('public.applications.success', $application->public_uuid))
|
||||
->assertOk()
|
||||
->assertSee($expectedUrl);
|
||||
}
|
||||
|
||||
private function application(string $status, array $extra = []): Application
|
||||
{
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
|
||||
return Application::query()->create(array_merge([
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'requested_position_id' => $position->id,
|
||||
'public_uuid' => (string) Str::uuid(),
|
||||
'source' => 'public',
|
||||
'status' => $status,
|
||||
'name' => 'Pemohon Ujian',
|
||||
'ic_number' => '900101109999',
|
||||
'phone_number' => '0123456789',
|
||||
'email' => 'ujian@example.test',
|
||||
'address' => 'Jalan Ujian',
|
||||
], $extra));
|
||||
}
|
||||
}
|
||||
285
tests/Feature/PublicApplicationTest.php
Normal file
285
tests/Feature/PublicApplicationTest.php
Normal file
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Position;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PublicApplicationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_applicant_can_register_during_registration_period(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
|
||||
$response = $this->post(route('public.applications.store', $pusat->public_uuid), $this->validPayload([
|
||||
'requested_position_code' => 'PAPM',
|
||||
'ic_number' => '900101101111',
|
||||
]));
|
||||
|
||||
$application = Application::query()->where('ic_number', '900101101111')->firstOrFail();
|
||||
|
||||
$response->assertRedirect(route('public.applications.success', $application->public_uuid));
|
||||
$this->assertSame('submitted', $application->status);
|
||||
$this->assertSame('public', $application->source);
|
||||
$this->assertSame($pusat->id, $application->pusat_mengundi_id);
|
||||
$this->assertCount(2, $application->documents);
|
||||
$this->assertTrue($application->bankVerification()->where('status', 'pending')->exists());
|
||||
$this->assertTrue($application->statusHistories()->where('to_status', 'submitted')->exists());
|
||||
|
||||
foreach ($application->documents as $document) {
|
||||
Storage::disk('local')->assertExists($document->path);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_applicant_cannot_register_after_registration_period(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$pusat->election->settings()->update([
|
||||
'registration_start_date' => now()->subWeeks(4)->toDateString(),
|
||||
'registration_end_date' => now()->subDay()->toDateString(),
|
||||
'is_registration_open' => true,
|
||||
'is_registration_open_override' => null,
|
||||
]);
|
||||
|
||||
$this->get(route('public.applications.create', $pusat->public_uuid))
|
||||
->assertOk()
|
||||
->assertSee('Pendaftaran Ditutup');
|
||||
|
||||
$this->post(route('public.applications.store', $pusat->public_uuid), $this->validPayload([
|
||||
'ic_number' => '900101101112',
|
||||
]))
|
||||
->assertSessionHasErrors('registration');
|
||||
|
||||
$this->assertDatabaseMissing('applications', [
|
||||
'ic_number' => '900101101112',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_applicant_cannot_register_without_required_documents(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$payload = $this->validPayload([
|
||||
'ic_number' => '900101101113',
|
||||
]);
|
||||
unset($payload['ic_document'], $payload['bank_statement']);
|
||||
|
||||
$this->post(route('public.applications.store', $pusat->public_uuid), $payload)
|
||||
->assertSessionHasErrors(['ic_document', 'bank_statement']);
|
||||
|
||||
$this->assertDatabaseMissing('applications', [
|
||||
'ic_number' => '900101101113',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_kp_applicant_can_choose_ktm_when_vacancy_exists(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$ktmAssignment = $this->ktmAssignment($pusat);
|
||||
|
||||
$this->post(route('public.applications.store', $pusat->public_uuid), $this->validPayload([
|
||||
'requested_position_code' => 'KP',
|
||||
'selected_ktm_assignment_id' => $ktmAssignment->id,
|
||||
'ic_number' => '900101101114',
|
||||
]))
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('applications', [
|
||||
'ic_number' => '900101101114',
|
||||
'selected_ktm_assignment_id' => $ktmAssignment->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_kp_applicant_cannot_choose_ktm_when_vacancy_is_full(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$ktmAssignment = $this->ktmAssignment($pusat);
|
||||
$kpPosition = Position::query()->where('code', 'KP')->firstOrFail();
|
||||
|
||||
foreach (range(1, 4) as $sequence) {
|
||||
StaffAssignment::query()->create([
|
||||
'election_id' => $pusat->election_id,
|
||||
'user_id' => User::factory()->create()->id,
|
||||
'position_id' => $kpPosition->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'saluran_mengundi_id' => $ktmAssignment->saluran_mengundi_id,
|
||||
'reports_to_assignment_id' => $ktmAssignment->id,
|
||||
'status' => 'active',
|
||||
'assigned_at' => now()->subMinutes($sequence),
|
||||
'source' => 'test',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->post(route('public.applications.store', $pusat->public_uuid), $this->validPayload([
|
||||
'requested_position_code' => 'KP',
|
||||
'selected_ktm_assignment_id' => $ktmAssignment->id,
|
||||
'ic_number' => '900101101115',
|
||||
]))
|
||||
->assertSessionHasErrors('selected_ktm_assignment_id');
|
||||
|
||||
$this->assertDatabaseMissing('applications', [
|
||||
'ic_number' => '900101101115',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_public_registration_is_blocked_when_same_ic_was_created_by_ktm(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'KP')->firstOrFail();
|
||||
|
||||
Application::query()->create([
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'public_uuid' => (string) Str::uuid(),
|
||||
'source' => 'created_by_ktm',
|
||||
'status' => 'submitted',
|
||||
'name' => 'Pemohon Dicipta KTM',
|
||||
'ic_number' => '900101101116',
|
||||
'phone_number' => '0123456789',
|
||||
'email' => 'ktm-created@example.test',
|
||||
'address' => 'Alamat ujian',
|
||||
'requested_position_id' => $position->id,
|
||||
]);
|
||||
|
||||
$this->post(route('public.applications.store', $pusat->public_uuid), $this->validPayload([
|
||||
'requested_position_code' => 'PAPM',
|
||||
'ic_number' => '900101101116',
|
||||
]))
|
||||
->assertSessionHasErrors('ic_number');
|
||||
|
||||
$this->assertSame(1, Application::query()->where('ic_number', '900101101116')->count());
|
||||
}
|
||||
|
||||
public function test_public_registration_is_unblocked_after_ktm_deletes_applicant(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'KP')->firstOrFail();
|
||||
|
||||
$ktmCreated = Application::query()->create([
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'public_uuid' => (string) Str::uuid(),
|
||||
'source' => 'created_by_ktm',
|
||||
'status' => 'submitted',
|
||||
'name' => 'Pemohon Dicipta KTM',
|
||||
'ic_number' => '900101101117',
|
||||
'phone_number' => '0123456789',
|
||||
'email' => 'ktm-to-delete@example.test',
|
||||
'address' => 'Alamat ujian',
|
||||
'requested_position_id' => $position->id,
|
||||
]);
|
||||
|
||||
// Simulate KTM delete: set status and soft-delete
|
||||
$ktmCreated->forceFill(['status' => 'deleted_by_ktm'])->save();
|
||||
$ktmCreated->delete();
|
||||
|
||||
// Public applicant with same IC must now be allowed to register
|
||||
$this->post(route('public.applications.store', $pusat->public_uuid), $this->validPayload([
|
||||
'requested_position_code' => 'PAPM',
|
||||
'ic_number' => '900101101117',
|
||||
]))
|
||||
->assertRedirect();
|
||||
|
||||
// Exactly 1 active application exists (the new one); deleted one is soft-deleted
|
||||
$this->assertSame(1, Application::query()->where('ic_number', '900101101117')->count());
|
||||
$this->assertSame(2, Application::withTrashed()->where('ic_number', '900101101117')->count());
|
||||
}
|
||||
|
||||
public function test_cancelled_application_does_not_block_new_registration(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
|
||||
|
||||
// Create an application and cancel it (status = cancelled, NOT soft-deleted)
|
||||
Application::query()->create([
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'public_uuid' => (string) Str::uuid(),
|
||||
'source' => 'public',
|
||||
'status' => 'cancelled',
|
||||
'name' => 'Pemohon Dibatalkan',
|
||||
'ic_number' => '900101101118',
|
||||
'phone_number' => '0123456789',
|
||||
'email' => 'cancelled@example.test',
|
||||
'address' => 'Alamat ujian',
|
||||
'requested_position_id' => $position->id,
|
||||
]);
|
||||
|
||||
// Same IC must be allowed to register fresh
|
||||
$this->post(route('public.applications.store', $pusat->public_uuid), $this->validPayload([
|
||||
'requested_position_code' => 'PAPM',
|
||||
'ic_number' => '900101101118',
|
||||
]))
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertSame(2, Application::query()->where('ic_number', '900101101118')->count());
|
||||
$this->assertSame(1, Application::query()->where('ic_number', '900101101118')->where('status', 'submitted')->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validPayload(array $overrides = []): array
|
||||
{
|
||||
return array_replace([
|
||||
'name' => 'Pemohon Ujian',
|
||||
'ic_number' => '900101101110',
|
||||
'phone_number' => '0123456789',
|
||||
'email' => 'pemohon@example.test',
|
||||
'address' => 'No. 1, Jalan Ujian',
|
||||
'requested_position_code' => 'KTM',
|
||||
'selected_ktm_assignment_id' => null,
|
||||
'bank_name' => 'Maybank',
|
||||
'bank_account_number' => '1234567890',
|
||||
'ic_document' => UploadedFile::fake()->create('ic.pdf', 120, 'application/pdf'),
|
||||
'bank_statement' => UploadedFile::fake()->create('bank.pdf', 120, 'application/pdf'),
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
private function ktmAssignment(PusatMengundi $pusatMengundi): StaffAssignment
|
||||
{
|
||||
$ktmPosition = Position::query()->where('code', 'KTM')->firstOrFail();
|
||||
|
||||
return StaffAssignment::query()
|
||||
->where('election_id', $pusatMengundi->election_id)
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->where('position_id', $ktmPosition->id)
|
||||
->where('status', 'active')
|
||||
->whereNotNull('saluran_mengundi_id')
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
166
tests/Feature/WheelchairManagementTest.php
Normal file
166
tests/Feature/WheelchairManagementTest.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\User;
|
||||
use App\Models\WheelchairAllocation;
|
||||
use App\Models\WheelchairTransaction;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WheelchairManagementTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_admin_can_create_or_update_wheelchair_allocation(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.management.wheelchairs.store'), [
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'allocated_quantity' => 4,
|
||||
'notes' => 'Tambahan kerusi roda.',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('wheelchair_allocations', [
|
||||
'election_id' => $pusat->election_id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'allocated_quantity' => 4,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_admin_can_record_taken_and_returned_wheelchairs(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$allocation = WheelchairAllocation::query()->firstOrFail();
|
||||
$allocation->update(['allocated_quantity' => 3]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.management.wheelchairs.transactions.store', $allocation), [
|
||||
'transaction_type' => 'taken',
|
||||
'quantity' => 2,
|
||||
'taken_by_name' => 'Petugas Logistik',
|
||||
'taken_at' => now()->format('Y-m-d H:i:s'),
|
||||
'notes' => 'Ambil pagi.',
|
||||
])
|
||||
->assertRedirect(route('admin.management.wheelchairs.show', $allocation));
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.management.wheelchairs.transactions.store', $allocation), [
|
||||
'transaction_type' => 'returned',
|
||||
'quantity' => 1,
|
||||
'returned_at' => now()->format('Y-m-d H:i:s'),
|
||||
'return_condition' => 'baik',
|
||||
])
|
||||
->assertRedirect(route('admin.management.wheelchairs.show', $allocation));
|
||||
|
||||
$this->assertDatabaseHas('wheelchair_transactions', [
|
||||
'wheelchair_allocation_id' => $allocation->id,
|
||||
'transaction_type' => 'taken',
|
||||
'quantity' => 2,
|
||||
'taken_by_name' => 'Petugas Logistik',
|
||||
'recorded_by_user_id' => $admin->id,
|
||||
]);
|
||||
$this->assertDatabaseHas('wheelchair_transactions', [
|
||||
'wheelchair_allocation_id' => $allocation->id,
|
||||
'transaction_type' => 'returned',
|
||||
'quantity' => 1,
|
||||
'return_condition' => 'baik',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_taken_quantity_cannot_exceed_available_allocation(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$allocation = WheelchairAllocation::query()->firstOrFail();
|
||||
$allocation->update(['allocated_quantity' => 2]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.management.wheelchairs.transactions.store', $allocation), [
|
||||
'transaction_type' => 'taken',
|
||||
'quantity' => 3,
|
||||
'taken_by_name' => 'Petugas Logistik',
|
||||
])
|
||||
->assertSessionHasErrors('quantity');
|
||||
|
||||
$this->assertDatabaseMissing('wheelchair_transactions', [
|
||||
'wheelchair_allocation_id' => $allocation->id,
|
||||
'quantity' => 3,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_return_quantity_cannot_exceed_outstanding_quantity(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$allocation = WheelchairAllocation::query()->firstOrFail();
|
||||
$allocation->update(['allocated_quantity' => 2]);
|
||||
|
||||
WheelchairTransaction::query()->create([
|
||||
'wheelchair_allocation_id' => $allocation->id,
|
||||
'transaction_type' => 'taken',
|
||||
'quantity' => 1,
|
||||
'taken_at' => now(),
|
||||
'taken_by_name' => 'Petugas Logistik',
|
||||
'recorded_by_user_id' => $admin->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->post(route('admin.management.wheelchairs.transactions.store', $allocation), [
|
||||
'transaction_type' => 'returned',
|
||||
'quantity' => 2,
|
||||
'return_condition' => 'baik',
|
||||
])
|
||||
->assertSessionHasErrors('quantity');
|
||||
}
|
||||
|
||||
public function test_allocation_cannot_be_reduced_below_outstanding_quantity(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$admin = User::query()->where('email', 'admin@prn.local')->firstOrFail();
|
||||
$allocation = WheelchairAllocation::query()->firstOrFail();
|
||||
$allocation->update(['allocated_quantity' => 3]);
|
||||
|
||||
WheelchairTransaction::query()->create([
|
||||
'wheelchair_allocation_id' => $allocation->id,
|
||||
'transaction_type' => 'taken',
|
||||
'quantity' => 2,
|
||||
'taken_at' => now(),
|
||||
'taken_by_name' => 'Petugas Logistik',
|
||||
'recorded_by_user_id' => $admin->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->patch(route('admin.management.wheelchairs.update', $allocation), [
|
||||
'election_id' => $allocation->election_id,
|
||||
'pusat_mengundi_id' => $allocation->pusat_mengundi_id,
|
||||
'allocated_quantity' => 1,
|
||||
])
|
||||
->assertSessionHasErrors('allocated_quantity');
|
||||
}
|
||||
|
||||
public function test_admin_kewangan_cannot_manage_wheelchairs(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$finance = User::query()->where('email', 'kewangan@prn.local')->firstOrFail();
|
||||
|
||||
$this->actingAs($finance)
|
||||
->get(route('admin.management.wheelchairs.index'))
|
||||
->assertForbidden();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user