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 $overrides * @return array */ 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(); } }