Files
radicall/tests/Feature/Services/Shift/ShiftScheduleServiceTest.php
T

366 lines
16 KiB
PHP

<?php
namespace Tests\Feature\Services\Shift;
use App\Enums\DocumentStatusEnum;
use App\Enums\ShiftDayEnum;
use App\Enums\UserEnum;
use App\Models\Document;
use App\Models\DocumentAssignment;
use App\Models\Shift;
use App\Models\User;
use App\Services\Shift\ShiftScheduleService;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
class ShiftScheduleServiceTest extends TestCase
{
use RefreshDatabase;
protected ShiftScheduleService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = app(ShiftScheduleService::class);
}
protected function doctor(): User
{
return User::factory()->create(['level' => UserEnum::Doctor->value]);
}
public function test_shifts_covering_moment_includes_dated_shift_within_hours(): void
{
$shift = Shift::factory()->onDate('2026-09-21')->between('08:00', '16:00')->create();
$covering = $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 10:00'));
$this->assertTrue($covering->contains('id', $shift->id));
}
public function test_shifts_covering_moment_excludes_dated_shift_outside_hours(): void
{
Shift::factory()->onDate('2026-09-21')->between('08:00', '16:00')->create();
$this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 16:00')));
$this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 07:59')));
}
public function test_shifts_covering_moment_includes_start_and_excludes_end(): void
{
Shift::factory()->onDate('2026-09-21')->between('08:00', '16:00')->create();
$this->assertCount(1, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 08:00')));
$this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 16:00')));
}
public function test_shifts_covering_moment_includes_weekly_shift_on_matching_weekday(): void
{
$shift = Shift::factory()->weekly(ShiftDayEnum::Monday)->between('08:00', '16:00')->create();
$this->assertTrue($this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 10:00'))->contains('id', $shift->id));
$this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-22 10:00')));
}
public function test_shifts_covering_moment_handles_overnight_shift_before_and_after_midnight(): void
{
$shift = Shift::factory()->weekly(ShiftDayEnum::Monday)->between('22:00', '02:00')->create();
$this->assertTrue($this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 23:30'))->contains('id', $shift->id));
$this->assertTrue($this->service->shiftsCoveringMoment(Carbon::parse('2026-09-22 01:59'))->contains('id', $shift->id));
$this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-22 02:00')));
$this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 21:59')));
}
public function test_shifts_covering_moment_handles_overnight_dated_shift_on_next_day(): void
{
$shift = Shift::factory()->onDate('2026-09-21')->between('22:00', '02:00')->create();
$this->assertTrue($this->service->shiftsCoveringMoment(Carbon::parse('2026-09-22 01:00'))->contains('id', $shift->id));
$this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-23 01:00')));
}
public function test_shifts_covering_moment_ignores_inactive_shifts(): void
{
Shift::factory()->onDate('2026-09-21')->between('08:00', '16:00')->inactive()->create();
$this->assertCount(0, $this->service->shiftsCoveringMoment(Carbon::parse('2026-09-21 10:00')));
}
public function test_available_doctors_at_returns_only_doctors_on_covering_shifts(): void
{
$doctor = $this->doctor();
$supervisor = User::factory()->create(['level' => UserEnum::Supervisor->value]);
Shift::factory()->onDate('2026-09-21')->forDoctor($doctor)->create();
Shift::factory()->onDate('2026-09-21')->create(['user_id' => $supervisor->id]);
Shift::factory()->onDate('2026-09-21')->create();
$available = $this->service->availableDoctorsAt(Carbon::parse('2026-09-21 10:00'));
$this->assertCount(1, $available);
$this->assertTrue($available->first()->is($doctor));
}
public function test_available_doctors_at_returns_empty_when_no_shift_covers_moment(): void
{
Shift::factory()->onDate('2026-09-21')->forDoctor()->create();
$this->assertCount(0, $this->service->availableDoctorsAt(Carbon::parse('2026-09-22 10:00')));
}
public function test_is_on_shift_reflects_doctor_assignment(): void
{
$doctor = $this->doctor();
$other = $this->doctor();
Shift::factory()->onDate('2026-09-21')->forDoctor($doctor)->create();
$this->assertTrue($this->service->isOnShift($doctor, Carbon::parse('2026-09-21 10:00')));
$this->assertFalse($this->service->isOnShift($other, Carbon::parse('2026-09-21 10:00')));
$this->assertFalse($this->service->isOnShift($doctor, Carbon::parse('2026-09-21 18:00')));
}
public function test_auto_select_doctor_picks_on_shift_doctor_with_fewest_open_documents(): void
{
$busy = $this->doctor();
$free = $this->doctor();
Shift::factory()->onDate('2026-09-21')->forDoctor($busy)->create();
Shift::factory()->onDate('2026-09-21')->forDoctor($free)->create();
DocumentAssignment::factory()->count(2)->create(['user_id' => $busy->id, 'document_id' => fn () => Document::factory()->inProgress()->create()->id]);
DocumentAssignment::factory()->create(['user_id' => $free->id, 'document_id' => Document::factory()->completed()->create()->id]);
$selected = $this->service->autoSelectDoctor(Carbon::parse('2026-09-21 10:00'));
$this->assertTrue($selected->is($free));
}
public function test_auto_select_doctor_breaks_ties_by_lowest_id(): void
{
$first = $this->doctor();
$second = $this->doctor();
Shift::factory()->onDate('2026-09-21')->forDoctor($second)->create();
Shift::factory()->onDate('2026-09-21')->forDoctor($first)->create();
$this->assertTrue($this->service->autoSelectDoctor(Carbon::parse('2026-09-21 10:00'))->is($first));
}
public function test_auto_select_doctor_falls_back_to_nearest_upcoming_shift(): void
{
$later = $this->doctor();
$sooner = $this->doctor();
Shift::factory()->onDate('2026-09-23')->between('08:00', '12:00')->forDoctor($later)->create();
Shift::factory()->onDate('2026-09-22')->between('08:00', '12:00')->forDoctor($sooner)->create();
$this->assertTrue($this->service->autoSelectDoctor(Carbon::parse('2026-09-21 10:00'))->is($sooner));
}
public function test_auto_select_doctor_returns_null_when_no_doctor_has_any_shift(): void
{
Shift::factory()->onDate('2026-09-21')->create();
$this->assertNull($this->service->autoSelectDoctor(Carbon::parse('2026-09-21 10:00')));
}
public function test_next_shift_start_returns_todays_shift_when_it_has_not_started(): void
{
$doctor = $this->doctor();
Shift::factory()->weekly(ShiftDayEnum::Monday)->between('14:00', '18:00')->forDoctor($doctor)->create();
$next = $this->service->nextShiftStart($doctor, Carbon::parse('2026-09-21 10:00'));
$this->assertTrue($next->eq(Carbon::parse('2026-09-21 14:00')));
}
public function test_next_shift_start_skips_shift_that_already_started_today(): void
{
$doctor = $this->doctor();
Shift::factory()->weekly(ShiftDayEnum::Monday)->between('08:00', '12:00')->forDoctor($doctor)->create();
$next = $this->service->nextShiftStart($doctor, Carbon::parse('2026-09-21 10:00'));
$this->assertTrue($next->eq(Carbon::parse('2026-09-28 08:00')));
}
public function test_next_shift_start_returns_earliest_among_multiple_shifts(): void
{
$doctor = $this->doctor();
Shift::factory()->weekly(ShiftDayEnum::Wednesday)->between('08:00', '12:00')->forDoctor($doctor)->create();
Shift::factory()->onDate('2026-09-22')->between('09:00', '12:00')->forDoctor($doctor)->create();
$next = $this->service->nextShiftStart($doctor, Carbon::parse('2026-09-21 10:00'));
$this->assertTrue($next->eq(Carbon::parse('2026-09-22 09:00')));
}
public function test_next_shift_start_returns_null_when_doctor_has_no_upcoming_shift(): void
{
$doctor = $this->doctor();
Shift::factory()->onDate('2026-09-20')->forDoctor($doctor)->create();
Shift::factory()->onDate('2026-09-30')->forDoctor($doctor)->create();
$this->assertNull($this->service->nextShiftStart($doctor, Carbon::parse('2026-09-21 10:00')));
}
public function test_has_conflict_returns_false_without_user(): void
{
$this->assertFalse($this->service->hasConflict(['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '08:00', 'end_time' => '12:00']));
}
public function test_has_conflict_detects_overlapping_weekly_shift_for_same_doctor(): void
{
$doctor = $this->doctor();
Shift::factory()->weekly(ShiftDayEnum::Monday)->between('08:00', '12:00')->forDoctor($doctor)->create();
$this->assertTrue($this->service->hasConflict(['user_id' => $doctor->id, 'day_of_week' => ShiftDayEnum::Monday->value, 'date' => null, 'start_time' => '11:00', 'end_time' => '14:00']));
$this->assertFalse($this->service->hasConflict(['user_id' => $doctor->id, 'day_of_week' => ShiftDayEnum::Monday->value, 'date' => null, 'start_time' => '12:00', 'end_time' => '14:00']));
}
public function test_has_conflict_ignores_other_doctors_and_inactive_shifts(): void
{
$doctor = $this->doctor();
Shift::factory()->weekly(ShiftDayEnum::Monday)->between('08:00', '12:00')->forDoctor()->create();
Shift::factory()->weekly(ShiftDayEnum::Monday)->between('08:00', '12:00')->forDoctor($doctor)->inactive()->create();
$this->assertFalse($this->service->hasConflict(['user_id' => $doctor->id, 'day_of_week' => ShiftDayEnum::Monday->value, 'date' => null, 'start_time' => '09:00', 'end_time' => '11:00']));
}
public function test_has_conflict_ignores_given_shift_id(): void
{
$doctor = $this->doctor();
$shift = Shift::factory()->weekly(ShiftDayEnum::Monday)->between('08:00', '12:00')->forDoctor($doctor)->create();
$this->assertFalse($this->service->hasConflict(['user_id' => $doctor->id, 'day_of_week' => ShiftDayEnum::Monday->value, 'date' => null, 'start_time' => '09:00', 'end_time' => '11:00'], $shift->id));
}
public function test_claim_assigns_open_shift_to_doctor(): void
{
$doctor = $this->doctor();
$shift = Shift::factory()->onDate('2026-09-21')->create();
$claimed = $this->service->claim($shift->id, $doctor);
$this->assertSame($doctor->id, $claimed->user_id);
$this->assertDatabaseHas('shifts', ['id' => $shift->id, 'user_id' => $doctor->id]);
}
public function test_claim_throws_when_shift_is_already_taken(): void
{
$doctor = $this->doctor();
$shift = Shift::factory()->onDate('2026-09-21')->forDoctor()->create();
try {
$this->service->claim($shift->id, $doctor);
$this->fail('ValidationException was not thrown');
} catch (ValidationException $e) {
$this->assertArrayHasKey('shift', $e->errors());
}
$this->assertNotSame($doctor->id, $shift->fresh()->user_id);
}
public function test_claim_throws_when_shift_is_inactive(): void
{
$shift = Shift::factory()->onDate('2026-09-21')->inactive()->create();
$this->expectException(ValidationException::class);
$this->service->claim($shift->id, $this->doctor());
}
public function test_claim_throws_when_shift_conflicts_with_doctors_schedule(): void
{
$doctor = $this->doctor();
Shift::factory()->onDate('2026-09-21')->between('08:00', '12:00')->forDoctor($doctor)->create();
$shift = Shift::factory()->onDate('2026-09-21')->between('10:00', '14:00')->create();
$this->expectException(ValidationException::class);
$this->service->claim($shift->id, $doctor);
}
public function test_claim_throws_for_unknown_shift(): void
{
$this->expectException(ModelNotFoundException::class);
$this->service->claim(999, $this->doctor());
}
public function test_release_clears_doctor_from_own_shift(): void
{
$doctor = $this->doctor();
$shift = Shift::factory()->onDate('2026-09-21')->forDoctor($doctor)->create();
$released = $this->service->release($shift, $doctor);
$this->assertNull($released->user_id);
$this->assertDatabaseHas('shifts', ['id' => $shift->id, 'user_id' => null]);
}
public function test_release_throws_when_shift_belongs_to_another_doctor(): void
{
$shift = Shift::factory()->onDate('2026-09-21')->forDoctor()->create();
$this->expectException(ValidationException::class);
$this->service->release($shift, $this->doctor());
}
public function test_overlaps_detects_same_weekday_time_overlap(): void
{
$first = new Shift(['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '08:00', 'end_time' => '12:00']);
$second = new Shift(['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '11:59', 'end_time' => '13:00']);
$third = new Shift(['day_of_week' => ShiftDayEnum::Tuesday->value, 'start_time' => '08:00', 'end_time' => '12:00']);
$this->assertTrue($this->service->overlaps($first, $second));
$this->assertFalse($this->service->overlaps($first, $third));
}
public function test_overlaps_treats_touching_boundaries_as_non_overlapping(): void
{
$first = new Shift(['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '08:00', 'end_time' => '12:00']);
$second = new Shift(['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '12:00', 'end_time' => '16:00']);
$this->assertFalse($this->service->overlaps($first, $second));
}
public function test_overlaps_compares_dated_shifts_by_date(): void
{
$first = new Shift(['date' => '2026-09-21', 'start_time' => '08:00', 'end_time' => '12:00']);
$sameDay = new Shift(['date' => '2026-09-21', 'start_time' => '10:00', 'end_time' => '14:00']);
$nextWeek = new Shift(['date' => '2026-09-28', 'start_time' => '10:00', 'end_time' => '14:00']);
$this->assertTrue($this->service->overlaps($first, $sameDay));
$this->assertFalse($this->service->overlaps($first, $nextWeek));
}
public function test_overlaps_compares_dated_and_weekly_shifts_by_weekday(): void
{
$dated = new Shift(['date' => '2026-09-21', 'start_time' => '08:00', 'end_time' => '12:00']);
$weekly = new Shift(['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '10:00', 'end_time' => '14:00']);
$this->assertTrue($this->service->overlaps($dated, $weekly));
}
public function test_overlaps_handles_overnight_shift_spilling_into_next_day(): void
{
$overnight = new Shift(['day_of_week' => ShiftDayEnum::Monday->value, 'start_time' => '22:00', 'end_time' => '02:00']);
$tuesdayMorning = new Shift(['day_of_week' => ShiftDayEnum::Tuesday->value, 'start_time' => '01:00', 'end_time' => '05:00']);
$tuesdayLater = new Shift(['day_of_week' => ShiftDayEnum::Tuesday->value, 'start_time' => '02:00', 'end_time' => '05:00']);
$this->assertTrue($this->service->overlaps($overnight, $tuesdayMorning));
$this->assertFalse($this->service->overlaps($overnight, $tuesdayLater));
}
public function test_overlaps_wraps_friday_overnight_into_saturday(): void
{
$overnight = new Shift(['day_of_week' => ShiftDayEnum::Friday->value, 'start_time' => '22:00', 'end_time' => '02:00']);
$saturday = new Shift(['day_of_week' => ShiftDayEnum::Saturday->value, 'start_time' => '01:00', 'end_time' => '05:00']);
$this->assertTrue($this->service->overlaps($overnight, $saturday));
}
}