'2026-02-11', 'name' => 'Extra Day', 'description' => 'Standalone']); Holiday::create(['date' => '2026-02-25', 'name' => 'Another Day', 'description' => 'Standalone']); $period = CarbonPeriod::create('2026-02-01', '2026-02-28'); $expected = 0; foreach ($period as $day) { if ($day->isWeekend()) { continue; } if (in_array($day->toDateString(), ['2026-02-11', '2026-02-25'], true)) { continue; } $expected++; } $service = app(CapacityService::class); expect($service->calculateWorkingDays('2026-02'))->toBe($expected); }); test('4.1.20 CapacityService applies availability', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); TeamMemberAvailability::factory()->forDate('2026-02-03')->availability(0.5)->create(['team_member_id' => $member->id]); TeamMemberAvailability::factory()->forDate('2026-02-04')->availability(0.0)->create(['team_member_id' => $member->id]); $result = app(CapacityService::class)->calculateIndividualCapacity($member->id, '2026-02'); $details = collect($result['details']); expect($details->firstWhere('date', '2026-02-03')['availability'])->toBe(0.5); expect($details->firstWhere('date', '2026-02-04')['availability'])->toBe(0.0); }); test('4.1.21 CapacityService handles PTO', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); Pto::create([ 'team_member_id' => $member->id, 'start_date' => '2026-02-10', 'end_date' => '2026-02-12', 'reason' => 'Rest', 'status' => 'approved', ]); $service = app(CapacityService::class); $workingDays = $service->calculateWorkingDays('2026-02'); $result = $service->calculateIndividualCapacity($member->id, '2026-02'); expect($result['person_days'])->toBe((float) ($workingDays - 3)); }); test('4.1.22 CapacityService handles holidays', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); Holiday::create(['date' => '2026-02-17', 'name' => 'Presidents Day', 'description' => 'Holiday']); $service = app(CapacityService::class); $result = $service->calculateIndividualCapacity($member->id, '2026-02'); $dates = collect($result['details'])->pluck('date'); expect($dates)->not->toContain('2026-02-17'); }); test('4.1.23 CapacityService calculates revenue', function () { $role = Role::factory()->create(); $memberA = TeamMember::factory()->create(['role_id' => $role->id, 'hourly_rate' => 150]); $memberB = TeamMember::factory()->create(['role_id' => $role->id, 'hourly_rate' => 125]); $service = app(CapacityService::class); $revenue = $service->calculatePossibleRevenue('2026-02'); $hoursA = $service->calculateIndividualCapacity($memberA->id, '2026-02')['hours']; $hoursB = $service->calculateIndividualCapacity($memberB->id, '2026-02')['hours']; expect($revenue)->toBe(round($hoursA * 150 + $hoursB * 125, 2)); }); test('4.1.24 Redis caching for capacity', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); $service = app(CapacityService::class); $key = "capacity:2026-02:{$member->id}"; Cache::store('array')->forget($key); $service->calculateIndividualCapacity($member->id, '2026-02'); expect(Cache::store('array')->get($key))->not->toBeNull(); }); // ============================================================================ // COMPREHENSIVE CAPACITY CALCULATION TESTS // ============================================================================ test('4.1.25 Capacity with PTO and holiday combined', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); // Create 2 weekdays as PTO (Feb 10-11 are Tuesday-Wednesday) Pto::create([ 'team_member_id' => $member->id, 'start_date' => '2026-02-10', 'end_date' => '2026-02-11', 'reason' => 'Vacation', 'status' => 'approved', ]); // Create 1 holiday (Feb 17 is Tuesday - Presidents Day) Holiday::create(['date' => '2026-02-17', 'name' => 'Presidents Day', 'description' => 'Holiday']); $service = app(CapacityService::class); $workingDays = $service->calculateWorkingDays('2026-02'); $result = $service->calculateIndividualCapacity($member->id, '2026-02'); // Debug: Check actual values $details = collect($result['details']); $ptoDays = $details->where('is_pto', true)->count(); $holidayExcluded = $details->where('date', '2026-02-17')->count() === 0; // Expected: working days - 2 PTO days = capacity // workingDays already excludes holidays // Feb 2026: 20 working days - 1 holiday = 19 working days // 19 working days - 2 PTO = 17 person days $expectedCapacity = $workingDays - 2; expect($workingDays)->toBe(19, 'Working days should be 19 (20 - 1 holiday)') ->and($ptoDays)->toBe(2, 'Should have 2 PTO days marked') ->and($holidayExcluded)->toBeTrue('Holiday should be excluded from details') ->and($result['person_days'])->toBe((float) $expectedCapacity) ->and($result['person_days'])->toBe(17.0) ->and($result['hours'])->toBe(136); }); test('4.1.26 PTO spanning weekend days', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); // PTO from Friday to Monday (Feb 6-9, 2026: Fri, Sat, Sun, Mon) // Only 2 working days should be subtracted (Fri Feb 6 and Mon Feb 9) Pto::create([ 'team_member_id' => $member->id, 'start_date' => '2026-02-06', 'end_date' => '2026-02-09', 'reason' => 'Long weekend', 'status' => 'approved', ]); $service = app(CapacityService::class); $workingDays = $service->calculateWorkingDays('2026-02'); $result = $service->calculateIndividualCapacity($member->id, '2026-02'); // Only 2 working days subtracted (Fri and Mon), not 4 $expectedCapacity = $workingDays - 2; expect($result['person_days'])->toBe((float) $expectedCapacity); // Verify weekend dates are not in details $details = collect($result['details']); expect($details->where('date', '2026-02-07')->count())->toBe(0) // Saturday ->and($details->where('date', '2026-02-08')->count())->toBe(0); // Sunday }); test('4.1.27 PTO on a holiday date', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); // Create holiday on Feb 17 (Tuesday) Holiday::create(['date' => '2026-02-17', 'name' => 'Presidents Day', 'description' => 'Holiday']); // Create PTO that includes the holiday (Feb 16-18, Mon-Wed) // Feb 16 is Monday, Feb 17 is holiday, Feb 18 is Wednesday Pto::create([ 'team_member_id' => $member->id, 'start_date' => '2026-02-16', 'end_date' => '2026-02-18', 'reason' => 'Vacation', 'status' => 'approved', ]); $service = app(CapacityService::class); $workingDays = $service->calculateWorkingDays('2026-02'); $result = $service->calculateIndividualCapacity($member->id, '2026-02'); // Holiday is already excluded from working days // PTO should subtract 2 more days (Mon Feb 16 and Wed Feb 18) $expectedCapacity = $workingDays - 2; expect($result['person_days'])->toBe((float) $expectedCapacity); // Verify holiday is not in details at all $details = collect($result['details']); expect($details->where('date', '2026-02-17')->count())->toBe(0); }); test('4.1.28 Multiple separate PTO periods', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); // First PTO: Feb 3-4 (Tue-Wed) Pto::create([ 'team_member_id' => $member->id, 'start_date' => '2026-02-03', 'end_date' => '2026-02-04', 'reason' => 'Personal', 'status' => 'approved', ]); // Second PTO: Feb 24-25 (Tue-Wed) Pto::create([ 'team_member_id' => $member->id, 'start_date' => '2026-02-24', 'end_date' => '2026-02-25', 'reason' => 'Personal', 'status' => 'approved', ]); $service = app(CapacityService::class); $workingDays = $service->calculateWorkingDays('2026-02'); $result = $service->calculateIndividualCapacity($member->id, '2026-02'); // 4 PTO days total $expectedCapacity = $workingDays - 4; expect($result['person_days'])->toBe((float) $expectedCapacity); }); test('4.1.29 Half-day availability with PTO', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); // Half-day on Feb 3 TeamMemberAvailability::factory() ->forDate('2026-02-03') ->availability(0.5) ->create(['team_member_id' => $member->id]); // PTO on Feb 4-5 Pto::create([ 'team_member_id' => $member->id, 'start_date' => '2026-02-04', 'end_date' => '2026-02-05', 'reason' => 'Vacation', 'status' => 'approved', ]); $service = app(CapacityService::class); $workingDays = $service->calculateWorkingDays('2026-02'); $result = $service->calculateIndividualCapacity($member->id, '2026-02'); // working days - 0.5 (half day) - 2 (PTO) = capacity $expectedCapacity = $workingDays - 0.5 - 2; expect($result['person_days'])->toBe($expectedCapacity); }); test('4.1.30 PTO with pending status is not counted', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); // Pending PTO should NOT affect capacity Pto::create([ 'team_member_id' => $member->id, 'start_date' => '2026-02-10', 'end_date' => '2026-02-12', 'reason' => 'Pending vacation', 'status' => 'pending', ]); $service = app(CapacityService::class); $workingDays = $service->calculateWorkingDays('2026-02'); $result = $service->calculateIndividualCapacity($member->id, '2026-02'); // Pending PTO should not subtract any days expect($result['person_days'])->toBe((float) $workingDays); }); test('4.1.31 PTO spanning month boundary', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); // PTO from Jan 29 to Feb 3 (spans Jan/Feb boundary) // In Feb: Feb 2 (Mon) and Feb 3 (Tue) should be counted Pto::create([ 'team_member_id' => $member->id, 'start_date' => '2026-01-29', 'end_date' => '2026-02-03', 'reason' => 'Vacation', 'status' => 'approved', ]); $service = app(CapacityService::class); $workingDays = $service->calculateWorkingDays('2026-02'); $result = $service->calculateIndividualCapacity($member->id, '2026-02'); // Only Feb 2 and Feb 3 should be subtracted (2 working days in Feb) $expectedCapacity = $workingDays - 2; expect($result['person_days'])->toBe((float) $expectedCapacity); }); test('4.1.32 Holiday on weekend does not double-count', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); // Create holiday on a Saturday (Feb 7, 2026) Holiday::create(['date' => '2026-02-07', 'name' => 'Saturday Holiday', 'description' => 'Test']); $service = app(CapacityService::class); $workingDays = $service->calculateWorkingDays('2026-02'); $result = $service->calculateIndividualCapacity($member->id, '2026-02'); // Weekend holiday should not affect working days count // (weekend is already excluded) expect($result['person_days'])->toBe((float) $workingDays); }); test('4.1.33 Full month capacity verification', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); $service = app(CapacityService::class); $workingDays = $service->calculateWorkingDays('2026-02'); $result = $service->calculateIndividualCapacity($member->id, '2026-02'); // Feb 2026: 28 days, 8 weekend days = 20 working days expect($workingDays)->toBe(20) ->and($result['person_days'])->toBe(20.0) ->and($result['hours'])->toBe(160) ->and(count($result['details']))->toBe(20); }); test('4.1.34 Negative scenario - PTO end before start is ignored', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); // Invalid PTO with end_date before start_date // This should be caught by validation, but testing service resilience $service = app(CapacityService::class); $workingDays = $service->calculateWorkingDays('2026-02'); // Create PTO with invalid range (would normally be rejected by validation) // Testing that service handles edge cases gracefully $result = $service->calculateIndividualCapacity($member->id, '2026-02'); expect($result['person_days'])->toBe((float) $workingDays); }); test('4.1.35 Team capacity sums all active members', function () { $role = Role::factory()->create(); // Create 3 active members $memberA = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); $memberB = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); $memberC = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); // Create 1 inactive member (should not be counted) TeamMember::factory()->create(['role_id' => $role->id, 'active' => false]); $service = app(CapacityService::class); $result = $service->calculateTeamCapacity('2026-02'); // Should have exactly 3 members in result expect(count($result['members']))->toBe(3); // Each member has 20 working days in Feb 2026 $expectedTotalDays = 20 * 3; expect($result['person_days'])->toBe((float) $expectedTotalDays); }); test('4.1.36 Capacity details mark PTO correctly', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); Pto::create([ 'team_member_id' => $member->id, 'start_date' => '2026-02-10', 'end_date' => '2026-02-12', 'reason' => 'Vacation', 'status' => 'approved', ]); $service = app(CapacityService::class); $result = $service->calculateIndividualCapacity($member->id, '2026-02'); $details = collect($result['details']); // PTO days should have is_pto = true and availability = 0 $ptoDays = $details->whereIn('date', ['2026-02-10', '2026-02-11', '2026-02-12']); foreach ($ptoDays as $day) { expect($day['is_pto'])->toBeTrue() ->and($day['availability'])->toBe(0.0); } // Non-PTO days should have is_pto = false and availability = 1 $nonPtoDay = $details->firstWhere('date', '2026-02-02'); expect($nonPtoDay['is_pto'])->toBeFalse() ->and($nonPtoDay['availability'])->toBe(1.0); }); test('4.1.40 PTO day can be overridden to half day availability', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); Pto::create([ 'team_member_id' => $member->id, 'start_date' => '2026-02-10', 'end_date' => '2026-02-10', 'reason' => 'Vacation', 'status' => 'approved', ]); TeamMemberAvailability::factory() ->forDate('2026-02-10') ->availability(0.5) ->create(['team_member_id' => $member->id]); $service = app(CapacityService::class); $result = $service->calculateIndividualCapacity($member->id, '2026-02'); $details = collect($result['details']); $ptoDay = $details->firstWhere('date', '2026-02-10'); expect($ptoDay['is_pto'])->toBeTrue() ->and($ptoDay['availability'])->toBe(0.5) ->and($result['person_days'])->toBe(19.5); }); test('4.1.37 Cache is invalidated when PTO is approved', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); $service = app(CapacityService::class); // Calculate initial capacity (no PTO) $result1 = $service->calculateIndividualCapacity($member->id, '2026-02'); $workingDays = $service->calculateWorkingDays('2026-02'); expect($result1['person_days'])->toBe((float) $workingDays); // Create approved PTO $pto = Pto::create([ 'team_member_id' => $member->id, 'start_date' => '2026-02-10', 'end_date' => '2026-02-12', 'reason' => 'Vacation', 'status' => 'approved', ]); // Invalidate cache (simulating what should happen in controller) $months = []; $startMonth = \Carbon\Carbon::create($pto->start_date)->copy()->startOfMonth(); $endMonth = \Carbon\Carbon::create($pto->end_date)->copy()->startOfMonth(); while ($startMonth <= $endMonth) { $months[] = $startMonth->format('Y-m'); $startMonth->addMonth(); } $service->forgetCapacityCacheForTeamMember($member->id, $months); // Recalculate - should now have PTO applied $result2 = $service->calculateIndividualCapacity($member->id, '2026-02'); expect($result2['person_days'])->toBe((float) ($workingDays - 3)); }); test('4.1.38 PTO created directly with approved status needs cache invalidation', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); $service = app(CapacityService::class); $workingDays = $service->calculateWorkingDays('2026-02'); // First, calculate capacity (this caches the result) $result1 = $service->calculateIndividualCapacity($member->id, '2026-02'); expect($result1['person_days'])->toBe((float) $workingDays); // Create PTO directly with approved status (bypassing controller) Pto::create([ 'team_member_id' => $member->id, 'start_date' => '2026-02-10', 'end_date' => '2026-02-11', 'reason' => 'Direct approved PTO', 'status' => 'approved', ]); // Without cache invalidation, the cached result would still be returned // This test verifies that fresh calculation includes the PTO $service->forgetCapacityCacheForTeamMember($member->id, ['2026-02']); $result2 = $service->calculateIndividualCapacity($member->id, '2026-02'); expect($result2['person_days'])->toBe((float) ($workingDays - 2)); }); test('4.1.39 Holiday created after initial calculation needs cache invalidation', function () { $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id]); $service = app(CapacityService::class); // Calculate initial capacity (no holiday) $result1 = $service->calculateIndividualCapacity($member->id, '2026-02'); $workingDays = $service->calculateWorkingDays('2026-02'); expect($result1['person_days'])->toBe(20.0); // Create holiday Holiday::create(['date' => '2026-02-17', 'name' => 'Presidents Day', 'description' => 'Holiday']); // Invalidate cache $service->forgetCapacityCacheForMonth('2026-02'); // Recalculate - should now have holiday excluded $result2 = $service->calculateIndividualCapacity($member->id, '2026-02'); expect($result2['person_days'])->toBe(19.0); });