This commit is contained in:
Saufi
2026-06-03 08:51:22 +08:00
commit a14d43fe34
347 changed files with 38197 additions and 0 deletions

View 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,
]);
}
}

View 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'),
);
}
}

View 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,
]);
}
}

View 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);
}
}

View 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',
]);
}
}

View 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('/');
}
}

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

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

View 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;
});
}
}

View 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');
}
}

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

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

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

View 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);
}
}

View 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);
}
}

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

View 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;
}
}

View 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,
]);
}
}

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

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

View 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));
}
}

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

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

10
tests/TestCase.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Tests\Unit;
use App\Models\Attendance;
use App\Models\Position;
use App\Models\PusatMengundi;
use App\Models\StaffAssignment;
use App\Services\Attendance\AttendanceSummaryService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AttendanceSummaryServiceTest extends TestCase
{
use RefreshDatabase;
public function test_attendance_summary_counts_present_absent_and_not_recorded(): void
{
$this->seed(DatabaseSeeder::class);
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
$position = Position::query()->where('code', 'PAPM')->firstOrFail();
$assignmentOne = StaffAssignment::query()->create([
'election_id' => $pusat->election_id,
'position_id' => $position->id,
'pusat_mengundi_id' => $pusat->id,
'status' => 'active',
'assigned_at' => now(),
'source' => 'test',
]);
$assignmentTwo = StaffAssignment::query()->create([
'election_id' => $pusat->election_id,
'position_id' => $position->id,
'pusat_mengundi_id' => $pusat->id,
'status' => 'active',
'assigned_at' => now(),
'source' => 'test',
]);
Attendance::query()->create([
'election_id' => $pusat->election_id,
'staff_assignment_id' => $assignmentOne->id,
'pusat_mengundi_id' => $pusat->id,
'position_id' => $position->id,
'status' => 'present',
]);
Attendance::query()->create([
'election_id' => $pusat->election_id,
'staff_assignment_id' => $assignmentTwo->id,
'pusat_mengundi_id' => $pusat->id,
'position_id' => $position->id,
'status' => 'absent',
]);
$summary = app(AttendanceSummaryService::class)->totalsForPusat($pusat);
$this->assertSame(5, $summary['total_staff']);
$this->assertSame(1, $summary['present']);
$this->assertSame(1, $summary['absent']);
$this->assertSame(3, $summary['not_recorded']);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*/
public function test_that_true_is_true(): void
{
$this->assertTrue(true);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Tests\Unit;
use App\Models\Position;
use App\Models\PusatMengundi;
use App\Models\StaffAssignment;
use App\Models\User;
use App\Services\Public\KtmVacancyService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class KtmVacancyServiceTest extends TestCase
{
use RefreshDatabase;
public function test_ktm_vacancy_options_are_removed_when_quota_is_full(): void
{
$this->seed(DatabaseSeeder::class);
$service = new KtmVacancyService;
$pusat = PusatMengundi::query()->where('code', 'PM001')->firstOrFail();
$ktmAssignment = $this->ktmAssignment($pusat);
$kpPosition = Position::query()->where('code', 'KP')->firstOrFail();
$this->assertNotEmpty($service->optionsForRoles($pusat, ['KP'])['KP']);
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->assertFalse($service->hasVacancyForAssignment($ktmAssignment->id, $pusat, 'KP'));
}
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();
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Tests\Unit;
use App\Models\Election;
use App\Services\RegistrationPeriodService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RegistrationPeriodServiceTest extends TestCase
{
use RefreshDatabase;
public function test_registration_period_uses_dates_and_open_flag(): void
{
$this->seed(DatabaseSeeder::class);
$service = new RegistrationPeriodService;
$election = Election::query()->where('code', 'PRN2026')->firstOrFail();
$this->assertTrue($service->isOpen($election));
$election->settings()->update([
'is_registration_open' => false,
'is_registration_open_override' => null,
]);
$election->refresh()->load('settings');
$this->assertFalse($service->isOpen($election));
}
public function test_registration_override_can_explicitly_open_or_close(): void
{
$this->seed(DatabaseSeeder::class);
$service = new RegistrationPeriodService;
$election = Election::query()->where('code', 'PRN2026')->firstOrFail();
$election->settings()->update([
'registration_start_date' => now()->subWeeks(4)->toDateString(),
'registration_end_date' => now()->subDay()->toDateString(),
'is_registration_open' => true,
'is_registration_open_override' => true,
]);
$election->refresh()->load('settings');
$this->assertTrue($service->isOpen($election));
$election->settings()->update([
'is_registration_open_override' => false,
]);
$election->refresh()->load('settings');
$this->assertFalse($service->isOpen($election));
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Tests\Unit;
use App\Support\SensitiveData;
use PHPUnit\Framework\TestCase;
class SensitiveDataTest extends TestCase
{
public function test_sensitive_data_masking(): void
{
$this->assertSame('9001****5555', SensitiveData::maskIc('900101105555'));
$this->assertSame('123***7890', SensitiveData::maskAccount('1234567890'));
$this->assertSame('012****789', SensitiveData::maskPhone('0123456789'));
$this->assertSame('-', SensitiveData::maskAccount(null));
}
}