base commit

This commit is contained in:
Mekan1206
2026-07-30 17:24:40 +05:00
commit a794dacd39
345 changed files with 29597 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Models\Department;
use App\Models\Employee;
use App\Models\Position;
use App\Models\Shift;
use App\Services\Employee\EmployeeService;
use Spatie\Activitylog\Models\Activity;
it('logs activity when an employee is created', function (): void {
$department = Department::factory()->create();
$position = Position::factory()->create();
$shift = Shift::factory()->create();
$employee = app(EmployeeService::class)->create([
'employee_number' => 'EMP-LOG-001',
'full_name' => 'Activity Test Employee',
'gender' => 'male',
'birth_date' => '1992-08-20',
'phone' => '+993 61 92 92 48',
'department_id' => $department->id,
'position_id' => $position->id,
'shift_id' => $shift->id,
'employment_status' => 'active',
'hire_date' => '2023-01-15',
]);
$activity = Activity::query()
->where('subject_type', Employee::class)
->where('subject_id', $employee->id)
->first();
expect($activity)->not->toBeNull()
->and($activity->event)->toBe('created')
->and($activity->description)->toContain('created');
});
it('logs activity when an employee is created via the model', function (): void {
$employee = Employee::factory()->create([
'employee_number' => 'EMP-LOG-002',
]);
expect(
Activity::query()
->where('subject_type', Employee::class)
->where('subject_id', $employee->id)
->where('event', 'created')
->exists(),
)->toBeTrue();
});
it('logs updated attributes when an employee is modified', function (): void {
$employee = Employee::factory()->create([
'full_name' => 'Original Name',
]);
$employee->update(['full_name' => 'Updated Name']);
$activity = Activity::query()
->where('subject_type', Employee::class)
->where('subject_id', $employee->id)
->where('event', 'updated')
->first();
expect($activity)->not->toBeNull()
->and($activity->properties->get('attributes'))->toHaveKey('full_name', 'Updated Name');
});

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,28 @@
<?php
declare(strict_types=1);
use App\Models\User;
it('redirects guests from the admin panel to login', function (): void {
$this->get('/admin')
->assertRedirect('/admin/login');
});
it('allows authenticated super admin users to access the admin panel', function (): void {
$admin = createAdminUser();
$this->actingAs($admin)
->get('/admin')
->assertSuccessful();
});
it('redirects authenticated non-admin users without panel access', function (): void {
seedRoles();
$user = User::factory()->create();
$this->actingAs($user)
->get('/admin')
->assertForbidden();
});

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
use App\DTOs\Import\EmployeeRowDto;
use App\Enums\ImportType;
use App\Models\Department;
use App\Models\Employee;
use App\Models\Position;
use App\Models\Shift;
use App\Services\Import\EmployeeImportTransformer;
use App\Services\Import\ImportService;
beforeEach(function (): void {
$this->department = Department::factory()->create(['name' => 'Engineering', 'code' => 'ENG']);
$this->position = Position::factory()->create(['name' => 'Developer']);
$this->shift = Shift::factory()->create(['name' => 'Day Shift', 'code' => 'DAY']);
$this->importService = app(ImportService::class);
$this->transformer = app(EmployeeImportTransformer::class);
});
it('skips duplicate employees during import', function (): void {
Employee::factory()->create([
'employee_number' => 'EMP-90001',
'national_id' => '1234567890',
]);
$rows = [[
'employee_number' => 'EMP-90001',
'full_name' => 'Duplicate Employee',
'national_id' => '1234567890',
'department' => 'Engineering',
'position' => 'Developer',
'shift' => 'Day Shift',
'hire_date' => '2024-01-01',
]];
$result = $this->importService->import(ImportType::Employees, $rows);
expect($result->imported)->toBe(0)
->and($result->skipped)->toBe(1)
->and($result->failed)->toBe(0)
->and(Employee::query()->where('employee_number', 'EMP-90001')->count())->toBe(1);
});
it('imports valid employee rows', function (): void {
$rows = [[
'employee_number' => 'EMP-90002',
'full_name' => 'Jane Smith',
'gender' => 'female',
'birth_date' => '1990-01-15',
'department' => 'Engineering',
'position' => 'Developer',
'shift' => 'Day Shift',
'hire_date' => '2024-03-15',
]];
$result = $this->importService->import(ImportType::Employees, $rows);
expect($result->imported)->toBe(1)
->and($result->skipped)->toBe(0)
->and($result->failed)->toBe(0);
$this->assertDatabaseHas('employees', [
'employee_number' => 'EMP-90002',
'full_name' => 'Jane Smith',
'department_id' => $this->department->id,
]);
});
it('reports validation errors for invalid employee rows', function (): void {
$rows = [[
'employee_number' => '',
'full_name' => '',
'department' => 'Unknown Department',
]];
$result = $this->importService->import(ImportType::Employees, $rows);
expect($result->imported)->toBe(0)
->and($result->failed)->toBe(1)
->and($result->errors)->not->toBeEmpty();
});
it('validates required fields and unknown references', function (): void {
$dto = EmployeeRowDto::fromMappedRow([
'employee_number' => '',
'full_name' => '',
'department' => 'Missing Department',
'position' => 'Missing Position',
'shift' => 'Missing Shift',
]);
$errors = $this->transformer->validate($dto);
expect($errors)->toContain('Employee number is required.')
->and($errors)->toContain('Full name is required.')
->and($errors)->toContain('Department "Missing Department" was not found.')
->and($errors)->toContain('Position "Missing Position" was not found.')
->and($errors)->toContain('Shift "Missing Shift" was not found.');
});
it('detects duplicates by employee number or national id', function (): void {
Employee::factory()->create([
'employee_number' => 'EMP-80001',
'national_id' => '9988776655',
]);
$duplicateByNumber = EmployeeRowDto::fromMappedRow([
'employee_number' => 'EMP-80001',
'full_name' => 'Someone Else',
]);
$duplicateByNationalId = EmployeeRowDto::fromMappedRow([
'employee_number' => 'EMP-80002',
'full_name' => 'Another Person',
'national_id' => '9988776655',
]);
expect($this->transformer->isDuplicate($duplicateByNumber))->toBeTrue()
->and($this->transformer->isDuplicate($duplicateByNationalId))->toBeTrue();
});

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
use App\Models\Bonus;
use App\Models\Department;
use App\Models\DisciplinaryReport;
use App\Models\Employee;
use App\Models\EmployeeDocument;
use App\Models\Explanation;
use App\Models\Gift;
use App\Models\Position;
use App\Models\Shift;
use App\Models\SickLeave;
use App\Models\UnpaidLeave;
use App\Models\Vacation;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Notification;
beforeEach(function (): void {
seedRoles();
Notification::fake();
});
it('defines expected employee relationships', function (): void {
$department = Department::factory()->create();
$position = Position::factory()->create();
$shift = Shift::factory()->create();
$employee = Employee::factory()->create([
'department_id' => $department->id,
'position_id' => $position->id,
'shift_id' => $shift->id,
]);
Vacation::factory()->count(2)->pending()->create([
'employee_id' => $employee->id,
'start_date' => '2024-06-01',
'end_date' => '2024-06-05',
'days' => 5,
]);
SickLeave::factory()->create([
'employee_id' => $employee->id,
'start_date' => '2024-03-01',
'end_date' => '2024-03-03',
'days' => 3,
]);
UnpaidLeave::factory()->pending()->create([
'employee_id' => $employee->id,
'start_date' => '2024-04-01',
'end_date' => '2024-04-10',
'days' => 10,
]);
DisciplinaryReport::factory()->create(['employee_id' => $employee->id]);
Explanation::factory()->create(['employee_id' => $employee->id]);
Bonus::factory()->create(['employee_id' => $employee->id]);
Gift::factory()->create(['employee_id' => $employee->id]);
EmployeeDocument::factory()->create(['employee_id' => $employee->id]);
$employee->load([
'department',
'position',
'shift',
'vacations',
'sickLeaves',
'unpaidLeaves',
'disciplinaryReports',
'explanations',
'bonuses',
'gifts',
'documents',
]);
expect($employee->department->is($department))->toBeTrue()
->and($employee->position->is($position))->toBeTrue()
->and($employee->shift->is($shift))->toBeTrue()
->and($employee->vacations)->toHaveCount(2)
->and($employee->sickLeaves)->toHaveCount(1)
->and($employee->unpaidLeaves)->toHaveCount(1)
->and($employee->disciplinaryReports)->toHaveCount(1)
->and($employee->explanations)->toHaveCount(1)
->and($employee->bonuses)->toHaveCount(1)
->and($employee->gifts)->toHaveCount(1)
->and($employee->documents)->toHaveCount(1);
});
it('enforces a unique employee number', function (): void {
Employee::factory()->create(['employee_number' => 'EMP-UNIQUE']);
expect(fn (): Employee => Employee::factory()->create(['employee_number' => 'EMP-UNIQUE']))
->toThrow(QueryException::class);
});
it('allows different employees to have distinct employee numbers', function (): void {
$first = Employee::factory()->create(['employee_number' => 'EMP-00001']);
$second = Employee::factory()->create(['employee_number' => 'EMP-00002']);
expect($first->employee_number)->not->toBe($second->employee_number);
});

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use App\Models\Department;
beforeEach(function (): void {
seedRoles();
});
it('allows viewers to read departments but not modify them', function (): void {
$viewer = createUserWithRole('viewer');
$department = Department::factory()->create();
expect($viewer->can('viewAny', Department::class))->toBeTrue()
->and($viewer->can('view', $department))->toBeTrue()
->and($viewer->can('create', Department::class))->toBeFalse()
->and($viewer->can('update', $department))->toBeFalse()
->and($viewer->can('delete', $department))->toBeFalse();
});
it('grants administrators full department access', function (): void {
$admin = createUserWithRole('administrator');
$department = Department::factory()->create();
expect($admin->can('viewAny', Department::class))->toBeTrue()
->and($admin->can('view', $department))->toBeTrue()
->and($admin->can('create', Department::class))->toBeTrue()
->and($admin->can('update', $department))->toBeTrue()
->and($admin->can('delete', $department))->toBeTrue();
});
it('grants super admin full department access', function (): void {
$superAdmin = createAdminUser();
$department = Department::factory()->create();
expect($superAdmin->can('viewAny', Department::class))->toBeTrue()
->and($superAdmin->can('create', Department::class))->toBeTrue()
->and($superAdmin->can('update', $department))->toBeTrue()
->and($superAdmin->can('delete', $department))->toBeTrue();
});

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use App\Models\Department;
use App\Models\Employee;
beforeEach(function (): void {
seedRoles();
});
it('allows department managers to manage employees in their own department', function (): void {
$department = Department::factory()->create();
$otherDepartment = Department::factory()->create();
$manager = createUserWithRole('department_manager', [
'department_id' => $department->id,
]);
$ownEmployee = Employee::factory()->create([
'department_id' => $department->id,
]);
$otherEmployee = Employee::factory()->create([
'department_id' => $otherDepartment->id,
]);
expect($manager->can('view', $ownEmployee))->toBeTrue()
->and($manager->can('update', $ownEmployee))->toBeTrue()
->and($manager->can('delete', $ownEmployee))->toBeTrue()
->and($manager->can('view', $otherEmployee))->toBeFalse()
->and($manager->can('update', $otherEmployee))->toBeFalse()
->and($manager->can('delete', $otherEmployee))->toBeFalse();
});
it('allows department managers to create employees', function (): void {
$department = Department::factory()->create();
$manager = createUserWithRole('department_manager', [
'department_id' => $department->id,
]);
expect($manager->can('create', Employee::class))->toBeTrue();
});
it('denies department managers access to employees without a matching department', function (): void {
$department = Department::factory()->create();
$manager = createUserWithRole('department_manager', [
'department_id' => $department->id,
]);
$unassignedEmployee = Employee::factory()->create([
'department_id' => $otherDepartment = Department::factory()->create()->id,
]);
expect($manager->can('view', $unassignedEmployee))->toBeFalse();
});
it('allows hr managers to access employees across departments', function (): void {
$hrManager = createUserWithRole('hr_manager');
$employee = Employee::factory()->create();
expect($hrManager->can('viewAny', Employee::class))->toBeTrue()
->and($hrManager->can('view', $employee))->toBeTrue()
->and($hrManager->can('update', $employee))->toBeTrue()
->and($hrManager->can('delete', $employee))->toBeTrue();
});

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use App\Enums\EmploymentStatus;
use App\Models\Department;
use App\Models\Employee;
use App\Models\Position;
use App\Models\Shift;
use App\Notifications\EmployeeTerminatedNotification;
use App\Services\Employee\EmployeeService;
use Carbon\Carbon;
use Illuminate\Support\Facades\Notification;
beforeEach(function (): void {
seedRoles();
Notification::fake();
$this->hrManager = createUserWithRole('hr_manager');
$this->service = app(EmployeeService::class);
});
it('creates an employee with the provided data', function (): void {
$department = Department::factory()->create();
$position = Position::factory()->create();
$shift = Shift::factory()->create();
$employee = $this->service->create([
'employee_number' => 'EMP-10001',
'full_name' => 'John Doe',
'gender' => 'male',
'birth_date' => '1990-05-15',
'phone' => '+993 61 92 92 48',
'department_id' => $department->id,
'position_id' => $position->id,
'shift_id' => $shift->id,
'employment_status' => EmploymentStatus::Active,
'hire_date' => '2024-01-01',
]);
expect($employee)->toBeInstanceOf(Employee::class)
->and($employee->employee_number)->toBe('EMP-10001')
->and($employee->full_name)->toBe('John Doe')
->and($employee->employment_status)->toBe(EmploymentStatus::Active);
$this->assertDatabaseHas('employees', [
'employee_number' => 'EMP-10001',
'full_name' => 'John Doe',
]);
});
it('terminates an employee and notifies hr managers', function (): void {
$employee = Employee::factory()->active()->create();
$terminationDate = Carbon::parse('2025-06-30');
$result = $this->service->terminate($employee, $terminationDate);
expect($result->employment_status)->toBe(EmploymentStatus::Terminated)
->and($result->termination_date->toDateString())->toBe('2025-06-30');
Notification::assertSentTo(
[$this->hrManager],
EmployeeTerminatedNotification::class,
fn (EmployeeTerminatedNotification $notification): bool => $notification->employee->is($result),
);
});

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use App\Enums\ApprovalStatus;
use App\Enums\VacationType;
use App\Models\Employee;
use App\Models\User;
use App\Models\Vacation;
use App\Notifications\VacationApprovedNotification;
use App\Notifications\VacationSubmittedNotification;
use App\Services\Vacation\VacationService;
use Illuminate\Support\Facades\Notification;
beforeEach(function (): void {
seedRoles();
Notification::fake();
$this->hrManager = createUserWithRole('hr_manager');
$this->approver = createUserWithRole('administrator');
$this->employee = Employee::factory()->create();
$this->service = app(VacationService::class);
});
it('submits a vacation request with calculated days and pending status', function (): void {
config(['hr.leave_calculation' => 'calendar']);
$vacation = $this->service->submit([
'employee_id' => $this->employee->id,
'type' => VacationType::Annual,
'start_date' => '2025-01-06',
'end_date' => '2025-01-10',
'return_date' => '2025-01-11',
'reason' => 'Family trip',
]);
expect($vacation)->toBeInstanceOf(Vacation::class)
->and($vacation->status)->toBe(ApprovalStatus::Pending)
->and($vacation->days)->toBe(5)
->and($vacation->employee_id)->toBe($this->employee->id);
Notification::assertSentTo(
[$this->hrManager],
VacationSubmittedNotification::class,
fn (VacationSubmittedNotification $notification): bool => $notification->vacation->is($vacation),
);
});
it('approves a vacation and notifies hr managers', function (): void {
$vacation = Vacation::factory()->pending()->create([
'employee_id' => $this->employee->id,
'start_date' => '2025-02-01',
'end_date' => '2025-02-05',
'days' => 5,
]);
$this->service->approve($vacation, $this->approver);
$vacation->refresh();
expect($vacation->status)->toBe(ApprovalStatus::Approved)
->and($vacation->approved_by)->toBe($this->approver->id)
->and($vacation->approved_at)->not->toBeNull();
Notification::assertSentTo(
[$this->hrManager],
VacationApprovedNotification::class,
fn (VacationApprovedNotification $notification): bool => $notification->vacation->is($vacation),
);
});
it('rejects a vacation without sending approval notifications', function (): void {
$vacation = Vacation::factory()->pending()->create([
'employee_id' => $this->employee->id,
'start_date' => '2025-03-01',
'end_date' => '2025-03-05',
'days' => 5,
]);
$this->service->reject($vacation, $this->approver);
$vacation->refresh();
expect($vacation->status)->toBe(ApprovalStatus::Rejected)
->and($vacation->approved_by)->toBe($this->approver->id)
->and($vacation->approved_at)->not->toBeNull();
Notification::assertNothingSent();
});

36
tests/Pest.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use App\Models\User;
use BezhanSalleh\FilamentShield\Support\Utils;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class)->in('Feature', 'Unit');
function seedRoles(): void
{
(new RoleSeeder)->run();
}
function createAdminUser(array $attributes = []): User
{
seedRoles();
$user = User::factory()->create($attributes);
$user->assignRole(Utils::getSuperAdminName());
return $user;
}
function createUserWithRole(string $role, array $attributes = []): User
{
seedRoles();
$user = User::factory()->create($attributes);
$user->assignRole($role);
return $user;
}

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,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,54 @@
<?php
declare(strict_types=1);
use App\Services\Leave\LeaveDaysCalculator;
use Carbon\Carbon;
beforeEach(function (): void {
$this->calculator = new LeaveDaysCalculator;
});
it('calculates inclusive calendar days between two dates', function (): void {
config(['hr.leave_calculation' => 'calendar']);
$days = $this->calculator->between(
Carbon::parse('2025-01-06'),
Carbon::parse('2025-01-12'),
);
expect($days)->toBe(7);
});
it('calculates business days excluding weekends', function (): void {
config(['hr.leave_calculation' => 'business']);
$days = $this->calculator->between(
Carbon::parse('2025-01-06'),
Carbon::parse('2025-01-12'),
);
expect($days)->toBe(5);
});
it('returns zero when the end date is before the start date', function (): void {
config(['hr.leave_calculation' => 'calendar']);
$days = $this->calculator->between(
Carbon::parse('2025-01-10'),
Carbon::parse('2025-01-06'),
);
expect($days)->toBe(0);
});
it('counts a single day when start and end are the same', function (): void {
config(['hr.leave_calculation' => 'calendar']);
$days = $this->calculator->between(
Carbon::parse('2025-01-06'),
Carbon::parse('2025-01-06'),
);
expect($days)->toBe(1);
});

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use App\Enums\ApprovalStatus;
use App\Enums\VacationType;
use App\Models\Employee;
use App\Models\Vacation;
use App\Services\Vacation\VacationBalanceService;
beforeEach(function (): void {
config(['hr.annual_leave_days' => 24]);
$this->service = new VacationBalanceService;
$this->employee = Employee::factory()->create();
});
it('returns annual leave entitlement from configuration', function (): void {
expect($this->service->entitlement())->toBe(24);
});
it('sums approved annual vacation days for the given year', function (): void {
Vacation::factory()->approved()->create([
'employee_id' => $this->employee->id,
'type' => VacationType::Annual,
'start_date' => '2025-03-01',
'end_date' => '2025-03-05',
'days' => 5,
]);
Vacation::factory()->approved()->create([
'employee_id' => $this->employee->id,
'type' => VacationType::Annual,
'start_date' => '2025-06-01',
'end_date' => '2025-06-03',
'days' => 3,
]);
Vacation::factory()->pending()->create([
'employee_id' => $this->employee->id,
'type' => VacationType::Annual,
'start_date' => '2025-07-01',
'end_date' => '2025-07-10',
'days' => 10,
]);
Vacation::factory()->approved()->create([
'employee_id' => $this->employee->id,
'type' => VacationType::Marriage,
'start_date' => '2025-04-01',
'end_date' => '2025-04-05',
'days' => 5,
]);
expect($this->service->takenAnnualDays($this->employee, 2025))->toBe(8);
});
it('calculates remaining annual leave days', function (): void {
$year = (int) now()->year;
Vacation::factory()->approved()->create([
'employee_id' => $this->employee->id,
'type' => VacationType::Annual,
'start_date' => "{$year}-02-01",
'end_date' => "{$year}-02-06",
'days' => 6,
]);
expect($this->service->remainingAnnualDays($this->employee))->toBe(18);
});
it('ignores rejected annual vacations when calculating taken days', function (): void {
$year = (int) now()->year;
Vacation::factory()->pending()->create([
'employee_id' => $this->employee->id,
'type' => VacationType::Annual,
'status' => ApprovalStatus::Rejected,
'start_date' => "{$year}-01-01",
'end_date' => "{$year}-01-05",
'days' => 5,
]);
expect($this->service->takenAnnualDays($this->employee))->toBe(0)
->and($this->service->remainingAnnualDays($this->employee))->toBe(24);
});