setTime(0, 0) ->getTimestamp(); } /** * شنبه‌ای دور، مخصوصِ تست‌های تعطیلات رسمی. * * `national_holidays` عمداً سراسری است و `db_test` هرگز ریست نمی‌شود، پس ردیفی که * اینجا ساخته شود روی **همهٔ** تست‌های دیگری که همان روز را می‌سنجند اثر می‌گذارد. * فاصله گرفتن از پنجرهٔ یک‌هفته‌ایِ بقیه + پاک کردن در tearDown، هر دو لازم‌اند. */ private function farSaturday(): int { return $this->dayAfter($this->nextSaturday(), 210); } protected function tearDown(): void { // ردیف سراسری را همان تستی که ساخته پاک می‌کند؛ وگرنه بدهی‌اش را تست بعدی می‌دهد. $this->em->createQuery('DELETE FROM App\Resource\Entity\NationalHoliday h')->execute(); // بدون clear()، همان ردیفِ حذف‌شده در identity map می‌ماند و `findByDate()` تستِ // بعدی آن را برمی‌گرداند؛ آن‌وقت upsert فکر می‌کند رکورد هست و چیزی نمی‌نویسد. // این تست‌ها تنها وقتی جدا اجرا می‌شدند سبز بودند — دقیقاً نشانهٔ همین. $this->em->clear(); parent::tearDown(); } private function dayAfter(int $midnight, int $days): int { return (new \DateTimeImmutable('@' . $midnight)) ->setTimezone(new \DateTimeZone(self::TEHRAN)) ->modify("+$days day") ->setTime(0, 0) ->getTimestamp(); } /** @return array{0: User, 1: DoctorAddress, 2: string} کاربر، شعبه، uuid منبع */ private function resourceWithShifts(array $days = [0, 1, 2, 3, 4]): array { [$user, , $address] = $this->clinicWithAddress(); $type = $this->resourceType($address, 'operator', 'اپراتور'); $created = $this->createResource($user, $address, $type, ['name' => 'اپراتور مریم']); $uuid = $created['data']['uuid']; $shifts = []; foreach ($days as $day) { $shifts[$day] = [['start_minute' => 540, 'end_minute' => 1020]]; // 09:00-17:00 } $this->authJson('PUT', "/api/v1/resource/$uuid/calendar", $user, ['days' => $shifts]); self::assertSame(200, $this->responseCode()); return [$user, $address, $uuid]; } private function availability(User $user, string $uuid, int $from, int $to): array { $body = $this->authJson('GET', "/api/v1/resource/$uuid/availability?from=$from&to=$to", $user); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); return $body['data']['days']; } public function testFiveWorkingDaysAndAnEmptyFriday(): void { [$user, , $uuid] = $this->resourceWithShifts(); $saturday = $this->nextSaturday(); $days = $this->availability($user, $uuid, $saturday, $this->dayAfter($saturday, 6)); self::assertCount(7, $days); $withHours = array_values(array_filter($days, static fn (array $d): bool => $d['intervals'] !== [])); self::assertCount(5, $withHours, 'شنبه تا چهارشنبه'); // ۶ = جمعه در قرارداد ۰=شنبه $friday = $days[6]; self::assertSame(6, $friday['day_of_week']); self::assertSame([], $friday['intervals']); self::assertContains('no_shift', $friday['reasons']); self::assertSame(480, $days[0]['total_minutes'], '۹ تا ۱۷ یعنی ۴۸۰ دقیقه'); } public function testAnExceptionEmptiesThatDay(): void { [$user, , $uuid] = $this->resourceWithShifts(); $saturday = $this->nextSaturday(); $tuesday = $this->dayAfter($saturday, 3); $this->authJson('POST', "/api/v1/resource/$uuid/exception", $user, [ 'type' => ResourceException::TYPE_LEAVE, 'starts_at' => $tuesday, 'ends_at' => $this->dayAfter($tuesday, 1), 'reason' => 'مرخصی استحقاقی', ]); self::assertSame(201, $this->responseCode()); $days = $this->availability($user, $uuid, $saturday, $this->dayAfter($saturday, 6)); self::assertSame([], $days[3]['intervals']); self::assertContains('exception', $days[3]['reasons']); self::assertNotSame([], $days[2]['intervals'], 'روزهای دیگر دست‌نخورده‌اند'); } /** استثنای نیم‌روزه فقط همان تکه را می‌بُرد، نه کل روز. */ public function testHalfDayExceptionCutsOnlyItsOwnWindow(): void { [$user, , $uuid] = $this->resourceWithShifts(); $saturday = $this->nextSaturday(); $monday = $this->dayAfter($saturday, 2); $this->authJson('POST', "/api/v1/resource/$uuid/exception", $user, [ 'type' => ResourceException::TYPE_MAINTENANCE, 'starts_at' => $monday + 14 * 3600, 'ends_at' => $monday + 18 * 3600, ]); self::assertSame(201, $this->responseCode()); $days = $this->availability($user, $uuid, $monday, $monday); self::assertCount(1, $days[0]['intervals']); self::assertSame($monday + 9 * 3600, $days[0]['intervals'][0]['start']); self::assertSame($monday + 14 * 3600, $days[0]['intervals'][0]['end']); self::assertSame(300, $days[0]['total_minutes'], '۹ تا ۱۴ یعنی ۳۰۰ دقیقه'); } /** دو استثنای هم‌پوشان مجازند و اتحاد گرفته می‌شود، نه خطا. */ public function testOverlappingExceptionsAreUnioned(): void { [$user, , $uuid] = $this->resourceWithShifts(); $saturday = $this->nextSaturday(); foreach ([[10, 13], [12, 16]] as [$startHour, $endHour]) { $this->authJson('POST', "/api/v1/resource/$uuid/exception", $user, [ 'type' => ResourceException::TYPE_ABSENCE, 'starts_at' => $saturday + $startHour * 3600, 'ends_at' => $saturday + $endHour * 3600, ]); self::assertSame(201, $this->responseCode()); } $days = $this->availability($user, $uuid, $saturday, $saturday); // ۹-۱۰ و ۱۶-۱۷ باقی می‌ماند؛ ۱۰ تا ۱۶ یکجا بریده می‌شود. self::assertCount(2, $days[0]['intervals']); self::assertSame(120, $days[0]['total_minutes']); } /** تعطیل رسمی بدون هیچ ثبت دستی، روز را برای همهٔ محیط‌ها می‌بندد. */ public function testNationalHolidayClosesTheDayForEveryone(): void { [$user, , $uuid] = $this->resourceWithShifts(); $saturday = $this->farSaturday(); $holidays = static::getContainer()->get(HolidayService::class); $jalali = static::getContainer()->get(\App\Representation\Service\JalaliDateService::class); [$jy, $jm, $jd] = $jalali->toJalali( (new \DateTimeImmutable('@' . $saturday))->setTimezone(new \DateTimeZone(self::TEHRAN)), ); $holidays->upsertNational($jy, $jm, $jd, 'تعطیل آزمایشی'); $days = $this->availability($user, $uuid, $saturday, $saturday); self::assertSame([], $days[0]['intervals']); self::assertContains('national_holiday', $days[0]['reasons']); } /** محیطی که آن روز کار می‌کند با override باز می‌شود — فقط منابع همان محیط. */ public function testTenantOverrideReopensANationalHoliday(): void { [$user, , $uuid] = $this->resourceWithShifts(); [$otherUser, , $otherUuid] = $this->resourceWithShifts(); $saturday = $this->farSaturday(); $holidays = static::getContainer()->get(HolidayService::class); $jalali = static::getContainer()->get(\App\Representation\Service\JalaliDateService::class); [$jy, $jm, $jd] = $jalali->toJalali( (new \DateTimeImmutable('@' . $saturday))->setTimezone(new \DateTimeZone(self::TEHRAN)), ); $holidays->upsertNational($jy, $jm, $jd, 'تعطیل آزمایشی'); $this->authJson('POST', '/api/v1/holiday-overrides', $user, [ 'date' => $saturday, 'is_working' => true, 'note' => 'کلینیک ما این روز باز است', ]); self::assertSame(201, $this->responseCode()); $mine = $this->availability($user, $uuid, $saturday, $saturday); self::assertNotSame([], $mine[0]['intervals'], 'محیطِ دارای override باز می‌شود'); $theirs = $this->availability($otherUser, $otherUuid, $saturday, $saturday); self::assertSame([], $theirs[0]['intervals'], 'محیط دیگر همچنان بسته است'); } /** جهت دوم: روزی که رسمی نیست ولی این محیط تعطیل است. */ public function testTenantOverrideCanCloseANormalDay(): void { [$user, , $uuid] = $this->resourceWithShifts(); $saturday = $this->nextSaturday(); $this->authJson('POST', '/api/v1/holiday-overrides', $user, [ 'date' => $saturday, 'is_working' => false, ]); self::assertSame(201, $this->responseCode()); $days = $this->availability($user, $uuid, $saturday, $saturday); self::assertSame([], $days[0]['intervals']); self::assertContains('tenant_holiday', $days[0]['reasons']); } /** * شیفت خودِ منبع تنها مرجع ساعت کاری است. * * تا پیش از حذف دامنهٔ شعبه، این شیفت با ساعت کاری شعبه تقاطع می‌گرفت و سه تست * جداگانه آن لایه را می‌سنجیدند. با رفتن شعبه، لایه هم رفت و «۴۸۰ دقیقه» یعنی * دقیقاً همان چیزی که در تقویم منبع نوشته شده. */ public function testTheResourceShiftAloneDecidesTheDay(): void { [$user, , $uuid] = $this->resourceWithShifts([0]); $saturday = $this->nextSaturday(); $days = $this->availability($user, $uuid, $saturday, $saturday); self::assertSame(480, $days[0]['total_minutes']); } public function testInactiveResourceHasNoAvailability(): void { [$user, , $uuid] = $this->resourceWithShifts(); $this->authJson('PATCH', "/api/v1/resource/$uuid", $user, ['active' => false]); $saturday = $this->nextSaturday(); $days = $this->availability($user, $uuid, $saturday, $saturday); self::assertSame([], $days[0]['intervals']); self::assertContains('resource_inactive', $days[0]['reasons']); } public function testRangeLongerThanTheCapIsRejected(): void { [$user, , $uuid] = $this->resourceWithShifts(); $saturday = $this->nextSaturday(); $tooFar = $this->dayAfter($saturday, 200); $this->authJson('GET', "/api/v1/resource/$uuid/availability?from=$saturday&to=$tooFar", $user); self::assertSame(422, $this->responseCode()); } }