createUser(['ROLE_USER', 'ROLE_CLINIC']); $clinic = new Clinic($user); $clinic->setName('کلینیک کاتالوگ'); $this->em->persist($clinic); $this->em->flush(); $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, $clinic, $section, $address]; } private function item(ServiceSection $section, string $name, ?int $solo, ?int $additional, int $price = 0): ServiceItem { $item = new ServiceItem($section, $name); $item->setSoloDurationMinutes($solo); $item->setAdditionalDurationMinutes($additional); $item->setPriceRials($price); $this->em->persist($item); $this->em->flush(); return $item; } /** @param string[] $itemUuids */ private function validate(User $user, array $itemUuids, array $extra = []): array { return $this->authJson('POST', '/api/v1/service-selection/validate', $user, $extra + [ 'item_uuids' => $itemUuids, ]); } private function group(User $user, ServiceItem $service, string $name, int $min, ?int $max, array $items): string { $created = $this->authJson('POST', "/api/v1/service-item/{$service->getUuid()}/groups", $user, [ 'name' => $name, 'min_select' => $min, 'max_select' => $max, ]); self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE)); $uuid = $created['data']['uuid']; $this->authJson('PUT', "/api/v1/item-group/$uuid/items", $user, [ 'items' => array_map(static fn (ServiceItem $i): array => ['item_uuid' => $i->getUuid()], $items), ]); self::assertSame(200, $this->responseCode()); return $uuid; } public function testDocumentExampleTwentyThreeMinutes(): void { [$user, , $section] = $this->clinicWithSection(); $face = $this->item($section, 'صورت', 15, 8); $bikini = $this->item($section, 'بیکینی', 12, 8); $body = $this->validate($user, [$face->getUuid(), $bikini->getUuid()]); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); self::assertTrue($body['data']['valid']); self::assertSame(23, $body['data']['total_duration_minutes'], 'نه ۲۷ — جمع ساده رد شده است'); } public function testSingleItemUsesSoloDuration(): void { [$user, , $section] = $this->clinicWithSection(); $bikini = $this->item($section, 'بیکینی', 12, 8); $body = $this->validate($user, [$bikini->getUuid()]); self::assertSame(12, $body['data']['total_duration_minutes']); } public function testMinSelectIsEnforced(): void { [$user, , $section] = $this->clinicWithSection(); $service = $this->item($section, 'لیزر', 0, 0); $face = $this->item($section, 'صورت', 15, 8); $groupUuid = $this->group($user, $service, 'نواحی', 1, 8, [$face]); $body = $this->validate($user, [], ['service_uuid' => $service->getUuid()]); self::assertFalse($body['data']['valid']); self::assertSame('min_select', $body['data']['errors'][0]['code']); self::assertSame($groupUuid, $body['data']['errors'][0]['group_uuid']); self::assertStringContainsString('نواحی', $body['data']['errors'][0]['message']); } public function testMaxSelectIsEnforced(): void { [$user, , $section] = $this->clinicWithSection(); $service = $this->item($section, 'لیزر', 0, 0); $items = []; foreach (range(1, 3) as $n) { $items[] = $this->item($section, "ناحیه $n", 10, 5); } $this->group($user, $service, 'نواحی', 1, 2, $items); $body = $this->validate( $user, array_map(static fn (ServiceItem $i): string => $i->getUuid(), $items), ['service_uuid' => $service->getUuid()], ); self::assertFalse($body['data']['valid']); self::assertSame('max_select', $body['data']['errors'][0]['code']); } /** `max_select = null` یعنی نامحدود، نه صفر. */ public function testNullMaxSelectMeansUnlimited(): void { [$user, , $section] = $this->clinicWithSection(); $service = $this->item($section, 'لیزر', 0, 0); $items = []; foreach (range(1, 5) as $n) { $items[] = $this->item($section, "ناحیه $n", 10, 5); } $this->group($user, $service, 'نواحی', 1, null, $items); $body = $this->validate( $user, array_map(static fn (ServiceItem $i): string => $i->getUuid(), $items), ['service_uuid' => $service->getUuid()], ); self::assertTrue($body['data']['valid']); } /** `min_select = 0` یعنی گروه اختیاری است. */ public function testZeroMinSelectMeansOptional(): void { [$user, , $section] = $this->clinicWithSection(); $service = $this->item($section, 'لیزر', 0, 0); $this->group($user, $service, 'افزودنی‌ها', 0, 3, [$this->item($section, 'ژل', 5, 5)]); $body = $this->validate($user, [], ['service_uuid' => $service->getUuid()]); self::assertTrue($body['data']['valid']); } public function testIncompatibleItemsAreRejectedOnceNotTwice(): void { [$user, , $section] = $this->clinicWithSection(); $bikini = $this->item($section, 'بیکینی', 12, 8); $fullBody = $this->item($section, 'فول‌بادی', 60, 40); $this->authJson('PUT', "/api/v1/service-item/{$bikini->getUuid()}/relations", $user, [ 'relations' => [[ 'related_item_uuid' => $fullBody->getUuid(), 'type' => ServiceItemRelation::TYPE_INCOMPATIBLE, ]], ]); self::assertSame(200, $this->responseCode()); $body = $this->validate($user, [$bikini->getUuid(), $fullBody->getUuid()]); self::assertFalse($body['data']['valid']); self::assertCount(1, $body['data']['errors'], 'یک جفت، یک خطا'); self::assertSame('incompatible', $body['data']['errors'][0]['code']); self::assertStringContainsString('بیکینی', $body['data']['errors'][0]['message']); self::assertStringContainsString('فول‌بادی', $body['data']['errors'][0]['message']); } /** ناسازگاری فقط وقتی خطاست که هر دو انتخاب شده باشند. */ public function testIncompatibilityIsSilentWhenOnlyOneIsSelected(): void { [$user, , $section] = $this->clinicWithSection(); $bikini = $this->item($section, 'بیکینی', 12, 8); $fullBody = $this->item($section, 'فول‌بادی', 60, 40); $this->authJson('PUT', "/api/v1/service-item/{$bikini->getUuid()}/relations", $user, [ 'relations' => [[ 'related_item_uuid' => $fullBody->getUuid(), 'type' => ServiceItemRelation::TYPE_INCOMPATIBLE, ]], ]); $body = $this->validate($user, [$bikini->getUuid()]); self::assertTrue($body['data']['valid']); } public function testMissingPrerequisiteIsReported(): void { [$user, , $section] = $this->clinicWithSection(); $peel = $this->item($section, 'پیلینگ', 20, 10); $cleanse = $this->item($section, 'پاک‌سازی', 10, 5); $this->authJson('PUT', "/api/v1/service-item/{$peel->getUuid()}/relations", $user, [ 'relations' => [[ 'related_item_uuid' => $cleanse->getUuid(), 'type' => ServiceItemRelation::TYPE_REQUIRES, ]], ]); self::assertSame(200, $this->responseCode()); $missing = $this->validate($user, [$peel->getUuid()]); self::assertFalse($missing['data']['valid']); self::assertSame('missing_prerequisite', $missing['data']['errors'][0]['code']); $satisfied = $this->validate($user, [$peel->getUuid(), $cleanse->getUuid()]); self::assertTrue($satisfied['data']['valid']); } /** حلقهٔ پیش‌نیاز باید هنگام **ثبت** رد شود، نه در اعتبارسنجی انتخاب. */ public function testPrerequisiteCycleIsRejectedAtWriteTime(): void { [$user, , $section] = $this->clinicWithSection(); $a = $this->item($section, 'الف', 10, 5); $b = $this->item($section, 'ب', 10, 5); $this->authJson('PUT', "/api/v1/service-item/{$a->getUuid()}/relations", $user, [ 'relations' => [['related_item_uuid' => $b->getUuid(), 'type' => ServiceItemRelation::TYPE_REQUIRES]], ]); self::assertSame(200, $this->responseCode()); $body = $this->authJson('PUT', "/api/v1/service-item/{$b->getUuid()}/relations", $user, [ 'relations' => [['related_item_uuid' => $a->getUuid(), 'type' => ServiceItemRelation::TYPE_REQUIRES]], ]); self::assertSame(422, $this->responseCode()); self::assertStringContainsString('حلقه', $body['errors'][0]['message']); } /** شعبه فقط مدت را عوض می‌کند؛ قیمت همیشه از خودِ سرویس است. */ public function testBranchOverrideChangesDurationButNotPrice(): void { [$user, , $section, $address] = $this->clinicWithSection(); $face = $this->item($section, 'صورت', 15, 8, 500_000); $this->authJson('PUT', "/api/v1/service-item/{$face->getUuid()}/branch-overrides", $user, [ 'overrides' => [[ 'address_uuid' => $address->getUuid(), 'solo_duration_minutes' => 25, ]], ]); self::assertSame(200, $this->responseCode()); $plain = $this->validate($user, [$face->getUuid()]); self::assertSame(500_000, $plain['data']['total_price_rials']); self::assertSame(15, $plain['data']['total_duration_minutes']); $atBranch = $this->validate($user, [$face->getUuid()], ['branch_uuid' => $address->getUuid()]); self::assertSame(500_000, $atBranch['data']['total_price_rials'], 'قیمت شعبه‌ای وجود ندارد'); self::assertSame(25, $atBranch['data']['total_duration_minutes']); } /** آیتم محیط دیگر ۴۰۴ می‌دهد نه ۴۲۲ — وجودش نباید لو برود. */ public function testForeignItemIsNotFound(): void { [$user] = $this->clinicWithSection(); [, , $otherSection] = $this->clinicWithSection(); $foreign = $this->item($otherSection, 'آیتم بیگانه', 10, 5); $this->validate($user, [$foreign->getUuid()]); self::assertSame(404, $this->responseCode()); } public function testAllErrorsAreReportedTogether(): void { [$user, , $section] = $this->clinicWithSection(); $service = $this->item($section, 'لیزر', 0, 0); $a = $this->item($section, 'الف', 10, 5); $b = $this->item($section, 'ب', 10, 5); $this->group($user, $service, 'نواحی', 1, 1, [$a, $b]); $this->authJson('PUT', "/api/v1/service-item/{$a->getUuid()}/relations", $user, [ 'relations' => [['related_item_uuid' => $b->getUuid(), 'type' => ServiceItemRelation::TYPE_INCOMPATIBLE]], ]); $body = $this->validate($user, [$a->getUuid(), $b->getUuid()], ['service_uuid' => $service->getUuid()]); self::assertFalse($body['data']['valid']); $codes = array_column($body['data']['errors'], 'code'); self::assertContains('max_select', $codes); self::assertContains('incompatible', $codes, 'کاربر نباید سه بار رفت‌وبرگشت کند'); } }