setTime($hour, 0) ->getTimestamp(); } /** * اشغال‌های یک منبع مشخص. * * `db_test` هرگز ریست نمی‌شود و تست‌های دیگر هم روی همین ساعت ردیف می‌سازند، پس * پرس‌وجو حتماً باید به منبعِ همین تست محدود شود — وگرنه تست، دادهٔ دیگران را * می‌شمارد. * * @return list */ private function occupancyOf(string $resourceUuid, ?int $startsAt = null): array { $qb = $this->em->createQueryBuilder() ->select('o') ->from(ResourceOccupancy::class, 'o') ->join('o.resource', 'r') ->where('r.uuid = :uuid') ->setParameter('uuid', $resourceUuid) ->orderBy('o.startsAt', 'ASC'); if ($startsAt !== null) { $qb->andWhere('o.startsAt = :start')->setParameter('start', $startsAt); } return $qb->getQuery()->getResult(); } /** @return array{user: User, doctor: Doctor, section: ServiceSection, address: DoctorAddress} */ private function clinic(): array { $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); $clinic = new Clinic($user); $clinic->setName('کلینیک رزرو'); $this->em->persist($clinic); $this->em->flush(); $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); $doctor = new Doctor($doctorUser, 'دکتر رزرو'); $this->em->persist($doctor); $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); $this->em->persist($section); $address = DoctorAddress::forClinic($clinic->getId()); $address->setName('شعبهٔ مرکزی'); $this->em->persist($address); $this->em->flush(); return ['user' => $user, 'doctor' => $doctor, 'section' => $section, 'address' => $address]; } private function service(ServiceSection $section, string $name, int $solo): ServiceItem { $section = $this->em->getRepository(ServiceSection::class)->find($section->getId()); $item = new ServiceItem($section, $name); $item->setSoloDurationMinutes($solo); $this->em->persist($item); $this->em->flush(); return $item; } private function type(DoctorAddress $address, string $code, string $name): ResourceType { $type = new ResourceType($address->tenantEntityType(), $address->tenantEntityId(), $code, $name); $this->em->persist($type); $this->em->flush(); return $type; } /** @param array $extra */ private function resource(User $user, DoctorAddress $address, ResourceType $type, string $name, array $extra = []): array { $created = $this->authJson('POST', '/api/v1/resource', $user, $extra + [ 'address_uuid' => $address->getUuid(), 'type_uuid' => $type->getUuid(), 'name' => $name, ]); self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE)); $this->authJson('PUT', "/api/v1/resource/{$created['data']['uuid']}/calendar", $user, [ 'days' => array_fill_keys(range(0, 6), [['start_minute' => 0, 'end_minute' => 1440]]), ]); return $created['data']; } /** @param list> $segments */ private function segments(User $user, ServiceItem $service, array $segments): void { $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, ['segments' => $segments]); self::assertSame(200, $this->responseCode()); } /** @param array> $assignment */ private function hold(User $user, ServiceItem $service, DoctorAddress $address, int $start, array $assignment): array { return $this->authJson('POST', '/api/v1/appointment-hold', $user, [ 'service_uuid' => $service->getUuid(), 'branch_uuid' => $address->getUuid(), 'start' => $start, 'assignment' => $assignment, ]); } /** یک سرویس بیست‌دقیقه‌ای که فقط یک اتاق می‌خواهد. */ private function simpleSetup(int $capacity = 1): array { $c = $this->clinic(); $service = $this->service($c['section'], 'ویزیت', 20); $room = $this->type($c['address'], 'room', 'اتاق'); $created = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱', ['capacity' => $capacity]); $this->segments($c['user'], $service, [ ['sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]], ]); return $c + ['service' => $service, 'room' => $created]; } public function testHoldCreatesOccupancyRowsPerSegmentAndResource(): void { $s = $this->simpleSetup(); $start = $this->nextSaturdayAt(10); $body = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); self::assertNotEmpty($body['data']['hold_uuid']); self::assertGreaterThan(time(), $body['data']['expires_at']); $this->em->clear(); $rows = $this->occupancyOf($s['room']['uuid'], $start); self::assertCount(1, $rows); self::assertSame(ResourceOccupancy::STATUS_HOLD, $rows[0]->getStatus()); self::assertSame('ویزیت', $rows[0]->getSegmentName()); } /** بعد از رزرو موقت، همان زمان دیگر پیشنهاد نمی‌شود. */ public function testHeldTimeDisappearsFromAvailability(): void { $s = $this->simpleSetup(); $start = $this->nextSaturdayAt(10); $saturday = $this->nextSaturdayAt(0); $before = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [ 'service_uuid' => $s['service']->getUuid(), 'branch_uuid' => $s['address']->getUuid(), 'from' => $saturday, 'to' => $saturday, 'step_minutes' => 20, ]); self::assertContains($start, array_column($before['data']['slots'], 'start')); $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); self::assertSame(201, $this->responseCode()); $after = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [ 'service_uuid' => $s['service']->getUuid(), 'branch_uuid' => $s['address']->getUuid(), 'from' => $saturday, 'to' => $saturday, 'step_minutes' => 20, ]); self::assertNotContains($start, array_column($after['data']['slots'], 'start')); } /** * ⭐ تست همزمانی — اصلی‌ترین تست این تسک. * * دو رزرو روی همان منبع و همان بازه: دقیقاً یکی ۲۰۱ و دیگری ۴۰۹. تضمین از قید * یکتای دیتابیس می‌آید نه از بررسی کد، و همین تست آن قید را مستقیم هم می‌سنجد. */ public function testSecondHoldOnTheSameResourceAndIntervalIsRejected(): void { $s = $this->simpleSetup(); $start = $this->nextSaturdayAt(11); $first = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); self::assertSame(201, $this->responseCode(), json_encode($first, JSON_UNESCAPED_UNICODE)); $second = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); self::assertSame(409, $this->responseCode()); self::assertSame('ERR_SLOT_TAKEN', $second['errors'][0]['code']); } /** * خودِ قید دیتابیس، مستقل از هر کدِ PHP: نوشتن مستقیم دو ردیف یکسان باید با * نقض کلید یکتا رد شود. اگر این تست بشکند، یعنی تضمین فقط در کد بوده است. */ public function testDatabaseItselfRefusesADuplicateBucketSeat(): void { $s = $this->simpleSetup(); $start = $this->nextSaturdayAt(12); $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); self::assertSame(201, $this->responseCode()); $this->em->clear(); $occupancy = $this->occupancyOf($s['room']['uuid'], $start)[0]; $bucket = OccupancyBucket::bucketsFor($start, $start + 1200)[0]; // اتصال جدا، نه اتصال مشترکِ تست: هم به «درخواست دیگر» وفادارتر است و هم // خطای عمدی، EntityManager مشترک را برای تست‌های بعدی خراب نمی‌کند. $connection = DriverManager::getConnection( $this->em->getConnection()->getParams(), ); $this->expectException(UniqueConstraintViolationException::class); try { $connection->insert('resource_occupancy_buckets', [ 'resource_id' => $occupancy->getResource()->getId(), 'occupancy_id' => $occupancy->getId(), 'bucket_at' => $bucket, 'seat' => 0, ]); } finally { $connection->close(); } } /** ظرفیت ۳: سه رزرو هم‌زمان می‌گذرند، چهارمی ۴۰۹. */ public function testCapacityThreeAllowsThreeConcurrentHoldsAndRefusesTheFourth(): void { $s = $this->simpleSetup(capacity: 3); $start = $this->nextSaturdayAt(13); foreach (range(1, 3) as $n) { $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); self::assertSame(201, $this->responseCode(), "رزرو $n باید بگذرد"); } $fourth = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); self::assertSame(409, $this->responseCode()); self::assertSame('ERR_SLOT_TAKEN', $fourth['errors'][0]['code']); } public function testConfirmMarksEverythingBooked(): void { $s = $this->simpleSetup(); $start = $this->nextSaturdayAt(14); $hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); self::assertSame(201, $this->responseCode()); $body = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [ 'hold_uuid' => $hold['data']['hold_uuid'], 'doctor_uuid' => $s['doctor']->getUuid(), ]); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); self::assertNotEmpty($body['data']['appointment_uuid']); $this->em->clear(); $rows = $this->occupancyOf($s['room']['uuid'], $start); self::assertSame(ResourceOccupancy::STATUS_BOOKED, $rows[0]->getStatus()); self::assertNotNull($rows[0]->getAppointmentId()); } /** رزرو منقضی ثبت نمی‌شود و آن زمان دوباره آزاد است. */ public function testExpiredHoldCannotBeConfirmed(): void { $s = $this->simpleSetup(); $start = $this->nextSaturdayAt(15); $hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); self::assertSame(201, $this->responseCode()); // مهلت را به عقب می‌بریم — همان کاری که گذر زمان می‌کند. $this->em->clear(); $entity = $this->em->getRepository(AppointmentHold::class)->findOneBy(['uuid' => $hold['data']['hold_uuid']]); $this->em->getConnection()->update( 'appointment_holds', ['expires_at' => time() - 60], ['id' => $entity->getId()], ); $body = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [ 'hold_uuid' => $hold['data']['hold_uuid'], 'doctor_uuid' => $s['doctor']->getUuid(), ]); self::assertSame(409, $this->responseCode()); self::assertSame('ERR_HOLD_EXPIRED', $body['errors'][0]['code']); } /** آزادسازی زودهنگام: زمان دوباره در جستجو ظاهر می‌شود. */ public function testReleasingAHoldFreesTheTimeAgain(): void { $s = $this->simpleSetup(); $start = $this->nextSaturdayAt(16); $saturday = $this->nextSaturdayAt(0); $hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); self::assertSame(201, $this->responseCode()); $this->authJson('DELETE', "/api/v1/appointment-hold/{$hold['data']['hold_uuid']}", $s['user']); self::assertSame(200, $this->responseCode()); $after = $this->authJson('POST', '/api/v1/appointment-availability', $s['user'], [ 'service_uuid' => $s['service']->getUuid(), 'branch_uuid' => $s['address']->getUuid(), 'from' => $saturday, 'to' => $saturday, 'step_minutes' => 20, ]); self::assertContains($start, array_column($after['data']['slots'], 'start')); } /** رزرو کاربر دیگر ۴۰۴ می‌گیرد، نه ۴۰۳ — وجودش نباید لو برود. */ public function testAnotherUsersHoldIsNotFound(): void { $s = $this->simpleSetup(); $start = $this->nextSaturdayAt(17); $hold = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); self::assertSame(201, $this->responseCode()); // مزاحم باید محیط معتبر خودش را داشته باشد، وگرنه ۴۰۳ «محیط انتخاب نشده» // می‌گیرد و تست چیزی را که ادعا می‌کند نمی‌سنجد. $intruder = $this->clinic()['user']; $this->authJson('POST', '/api/v1/appointment-confirm', $intruder, [ 'hold_uuid' => $hold['data']['hold_uuid'], 'doctor_uuid' => $s['doctor']->getUuid(), ]); self::assertSame(404, $this->responseCode()); } /** نیازمندی‌ای که در `assignment` منبع ندارد → ۴۲۲، پیش از هر رزروی. */ public function testAssignmentMissingARoleIsRejected(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'لیزر', 20); $room = $this->type($c['address'], 'room', 'اتاق'); $operator = $this->type($c['address'], 'operator', 'اپراتور'); $roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱'); $this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱'); $this->segments($c['user'], $service, [ ['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [ ['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()], ]], ]); $body = $this->hold($c['user'], $service, $c['address'], $this->nextSaturdayAt(18), [ 'room' => [$roomRes['uuid']], // اپراتور نیامده ]); self::assertSame(422, $this->responseCode()); self::assertStringContainsString('اپراتور', $body['errors'][0]['message']); } /** * ⭐ آزادسازی ظرفیت حفظ می‌شود: اپراتور در بخش «انتظار» ردیف اشغال **ندارد**. */ public function testOperatorHasNoOccupancyDuringTheWaitingSegment(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'لیزر', 20); $room = $this->type($c['address'], 'room', 'اتاق'); $operator = $this->type($c['address'], 'operator', 'اپراتور'); $roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱'); $opRes = $this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱'); $this->segments($c['user'], $service, [ ['sequence' => 1, 'name' => 'بی‌حسی', 'duration_minutes' => 5, 'requirements' => [['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()]]], ['sequence' => 2, 'name' => 'انتظار', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $room->getUuid()]]], ['sequence' => 3, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()]]], ]); $start = $this->nextSaturdayAt(19); $body = $this->hold($c['user'], $service, $c['address'], $start, [ 'room' => [$roomRes['uuid']], 'operator' => [$opRes['uuid']], ]); self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); $this->em->clear(); $operatorRows = $this->occupancyOf($opRes['uuid']); self::assertCount(2, $operatorRows, 'دو بخش، نه یک بازهٔ پیوستهٔ ۵۵ دقیقه‌ای'); self::assertSame($start, $operatorRows[0]->getStartsAt()); self::assertSame($start + 5 * 60, $operatorRows[0]->getEndsAt()); self::assertSame($start + 35 * 60, $operatorRows[1]->getStartsAt()); // اتاق برعکس: هر سه بخش را می‌گیرد. self::assertCount(3, $this->occupancyOf($roomRes['uuid'])); } /** رزرو نیمه‌کاره نمی‌ماند: اگر منبع دوم جا نداشت، اولی هم آزاد می‌شود. */ public function testPartialHoldIsRolledBack(): void { $c = $this->clinic(); $service = $this->service($c['section'], 'لیزر', 20); $room = $this->type($c['address'], 'room', 'اتاق'); $operator = $this->type($c['address'], 'operator', 'اپراتور'); $roomRes = $this->resource($c['user'], $c['address'], $room, 'اتاق ۱'); $opRes = $this->resource($c['user'], $c['address'], $operator, 'اپراتور ۱'); $this->segments($c['user'], $service, [ ['sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20, 'requirements' => [ ['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()], ]], ]); $start = $this->nextSaturdayAt(20); // اپراتور را از پیش می‌گیریم تا رزرو دوم روی او شکست بخورد. $onlyOperator = $this->service($c['section'], 'کار اپراتور', 20); $this->segments($c['user'], $onlyOperator, [ ['sequence' => 1, 'name' => 'کار', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $operator->getUuid()]]], ]); $this->hold($c['user'], $onlyOperator, $c['address'], $start, ['operator' => [$opRes['uuid']]]); self::assertSame(201, $this->responseCode()); $body = $this->hold($c['user'], $service, $c['address'], $start, [ 'room' => [$roomRes['uuid']], 'operator' => [$opRes['uuid']], ]); self::assertSame(409, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); // اتاق نباید قفل مانده باشد. $this->em->clear(); $roomRows = $this->em->createQuery( 'SELECT o FROM App\Appointment\Availability\Entity\ResourceOccupancy o JOIN o.resource r WHERE r.uuid = :uuid AND o.status IN (:blocking)' ) ->setParameter('uuid', $roomRes['uuid']) ->setParameter('blocking', ResourceOccupancy::BLOCKING_STATUSES) ->getResult(); self::assertSame([], $roomRows, 'اتاق نباید از رزروِ شکست‌خورده قفل بماند'); } /** * جابه‌جایی از بیرون شبیه «لغو + رزرو» است، ولی برای بیمار یک اتفاق است. * * رویداد `AppointmentRescheduled` همین را می‌گوید؛ بدون آن، مصرف‌کننده‌ای که فقط * `AppointmentCancelled` را می‌شنود برای بیماری که هنوز نوبت دارد پیام لغو می‌فرستد. */ public function testRebookingMovesTheAppointmentAndRecordsTheEvent(): void { $s = $this->simpleSetup(); $start = $this->nextSaturdayAt(14); $first = $this->hold($s['user'], $s['service'], $s['address'], $start, ['room' => [$s['room']['uuid']]]); $book = $this->authJson('POST', '/api/v1/appointment-confirm', $s['user'], [ 'hold_uuid' => $first['data']['hold_uuid'], 'doctor_uuid' => $s['doctor']->getUuid(), ]); self::assertSame(200, $this->responseCode(), json_encode($book, JSON_UNESCAPED_UNICODE)); $newStart = $start + 2 * 3600; $second = $this->hold($s['user'], $s['service'], $s['address'], $newStart, ['room' => [$s['room']['uuid']]]); self::assertSame(201, $this->responseCode()); $moved = $this->authJson( 'POST', "/api/v1/appointment/{$book['data']['appointment_uuid']}/rebook", $s['user'], ['hold_uuid' => $second['data']['hold_uuid']], ); self::assertSame(200, $this->responseCode(), json_encode($moved, JSON_UNESCAPED_UNICODE)); self::assertSame($newStart, $moved['data']['starts_at']); $event = $this->em->getRepository(\App\Shared\Event\Entity\DomainEventLog::class) ->findOneBy(['name' => \App\Shared\Event\DomainEvents::APPOINTMENT_RESCHEDULED], ['id' => 'DESC']); self::assertNotNull($event); self::assertSame($start, $event->getPayload()['previous_start']); self::assertSame($newStart, $event->getPayload()['new_start']); } }