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