From 5d2594ff876a7d26defdcb18849220772ce4c84d Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 18 Aug 2026 16:31:52 +0330 Subject: [PATCH] feat(privacy): keep venue phone numbers out of every public response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A doctor's office number sat next to the address on the public profile and in the anonymous API payload, so harvesting the phone number of every practice in the country was one unauthenticated request away. Street address and map coordinates stay public — a patient needs those to find the place — but the phone is now opt-in per caller: DoctorAddress::toArray() and the clinic serializers only emit it when told to, and the public doctor/clinic endpoints tell them to only when the caller may edit that profile (the same can_edit they already compute). Owner-facing address CRUD keeps returning it unchanged. The patient still gets the number where it is actually useful — their own appointment. That payload also stops guessing: it used to serialise the doctor's *first* address, so a booking made at the clinic or at a second office showed the wrong street entirely. It now resolves the address recorded on the appointment itself, which works the same for a personal office and a clinic branch, and falls back to the clinic's own number when the address has none. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/appointment.md | 8 +- docs/api/clinic.md | 4 +- docs/api/doctor.md | 11 +- .../Controller/AppointmentController.php | 40 ++++++- src/Clinic/Controller/ClinicController.php | 10 +- src/Clinic/Entity/Clinic.php | 10 +- src/Doctor/Controller/AddressController.php | 3 +- src/Doctor/Controller/DoctorController.php | 24 ++-- src/Doctor/Entity/Doctor.php | 8 +- src/Doctor/Entity/DoctorAddress.php | 13 +- tests/Clinic/ClinicContactSourceTest.php | 34 +++++- tests/Doctor/AddressPhoneIsNotPublicTest.php | 111 ++++++++++++++++++ 12 files changed, 244 insertions(+), 32 deletions(-) create mode 100644 tests/Doctor/AddressPhoneIsNotPublicTest.php diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 40e08616..f33bd01f 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -459,7 +459,13 @@ Get appointment detail. } } ``` -> `doctor.specialties` آرایه (ممکن است خالی)؛ `address` اولین آدرس پزشک است (ممکن است `null` اگر پزشک آدرسی ندارد). `address.map.latitude/longitude` رشته یا `null`. تاریخ‌ها Unix. + +> ℹ️ `address` is the venue recorded on this appointment (`address_id`) — the doctor's own +> office or the clinic branch, whichever the booking was made at — and it is the only place +> `telephone` is returned. When that record carries no number, the clinic's own number takes +> its place. Public doctor and clinic responses never carry a phone number. + +> `doctor.specialties` آرایه (ممکن است خالی)؛ `address` آدرسِ ثبت‌شدهٔ همین نوبت است و برای نوبت‌های قدیمیِ بدون `address_id` ممکن است `null` باشد. `address.map.latitude/longitude` رشته یا `null`. تاریخ‌ها Unix. ### انتخاب بیمهٔ نوبت diff --git a/docs/api/clinic.md b/docs/api/clinic.md index aec2239b..2a355ed6 100644 --- a/docs/api/clinic.md +++ b/docs/api/clinic.md @@ -167,6 +167,8 @@ limited to the whitelist under `PATCH /api/v1/clinic/{uuid}`. } ``` +> 🔒 `phone`/`phone_number` are `null` unless the caller may edit the clinic (`can_edit: true`). The number is not public data; the patient sees the venue phone on their own appointment instead. +> > `city`/`state`/`map`/`location`/`phone`/`phone_number` are all resolved from the clinic's **address** (`DoctorAddress` linked by `clinic_id`), not from columns on the clinic. `location` and `phone`/`phone_number` fall back to the deprecated `clinics.address` / `clinics.telephone` columns only when the address record has no value — reading them from different rows made one response describe two different places. Each is an array with a single object (or empty `[]` if the clinic has no address). `doctors` is a **count**; the actual doctor list comes from `GET /api/v1/clinic/doctor-list/{clinicUuid}` (`doctor_list` here is always `null`). ### معنای `is_active` @@ -330,7 +332,7 @@ List clinics with pagination. | `doctors_count` | integer | Number of doctors linked to the clinic | | `city` | string\|null | City name, resolved from the clinic's address (`DoctorAddress`) | | `state` | string\|null | Province name, resolved from the clinic's address (`DoctorAddress`) | -| `phone` / `phone_number` | string\|null | Contact number from the clinic's address (`DoctorAddress`), falling back to the deprecated `clinics.telephone` column | +| `phone` / `phone_number` | string\|null | Contact number from the clinic's address (`DoctorAddress`), falling back to the deprecated `clinics.telephone` column. `null` for anyone who cannot edit the clinic, and always `null` in the public list. | | `24_7` | boolean | Open 24/7 flag | | `field_working_days` | string\|null | Working days/hours description | diff --git a/docs/api/doctor.md b/docs/api/doctor.md index 6e62a816..e9dc456e 100644 --- a/docs/api/doctor.md +++ b/docs/api/doctor.md @@ -133,7 +133,7 @@ limited to the whitelist under `PATCH /api/v1/doctor/{uuid}`. "address": [], "state": [], "city": [], - "clinics": [{ "uuid": "...", "name": "کلینیک الوند", "address": "...", "telephone": "..." }], + "clinics": [{ "uuid": "...", "name": "کلینیک الوند", "address": "...", "telephone": null }], "representation": { "id": 12, "uuid": "9c1...", "full_name": "علی محمدی" } } } @@ -143,6 +143,13 @@ limited to the whitelist under `PATCH /api/v1/doctor/{uuid}`. > ⚠️ **Double-nested:** Frontend extracts with `data?.data?.data` > > ℹ️ `representation` نماینده‌ی مالکِ پزشک است؛ برای پزشکِ بدون نماینده `null`. +> +> 🔒 **Phone numbers are not public.** `address[].telephone` and `clinics[].telephone` +> are `null` for anonymous callers and only carry a value when `can_edit` is `true` +> (the profile owner, its representative, or an admin). Street address and map +> coordinates stay public — a patient needs them to find the place. The venue phone +> reaches the patient through their own appointment (`GET /api/v1/appointments/user`), +> not through the public profile. ### Errors | Code | HTTP | Description | @@ -171,7 +178,7 @@ Get doctor detail for clinic owner — only doctors who are members of the authe "uuid": "...", "title": "علی احمدی", "specialties": [...], - "clinics": [{ "uuid": "...", "name": "کلینیک نور", "address": "...", "telephone": "..." }] + "clinics": [{ "uuid": "...", "name": "کلینیک نور", "address": "...", "telephone": "021..." }] } } } diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 6e436162..d26aa68a 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -725,7 +725,7 @@ class AppointmentController extends BaseController return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403); } - $payload = $appointment->toArray(); + $payload = $this->appointmentWithVenue($appointment); /** * نوبتِ سرویسیِ کلینیک `service_section_id` را روی خودِ نوبت نمی‌نویسد؛ فقط @@ -893,13 +893,49 @@ class AppointmentController extends BaseController // `toArray()` هم داخل محدوده است: proxyهای پزشک و کلینیک آنجا باز می‌شوند و // با فیلترِ برگشته، Doctrine `EntityNotFoundException` می‌دهد. $rows = $this->tenantScope->withoutFilter(fn () => array_map( - fn(Appointment $a) => $a->toArray(), + fn(Appointment $a) => $this->appointmentWithVenue($a), $this->appointmentRepo->findByUser($user, $status), )); return $this->success(['data' => $rows]); } + /** + * نوبت به‌همراه نشانیِ واقعیِ همان نوبت — با شمارهٔ تماس. + * + * `Appointment::toArray()` نشانی را از «اولین آدرسِ پزشک» برمی‌داشت، پس بیماری که + * نوبتش در کلینیک یا در شعبهٔ دوم بود، نشانیِ مطبِ شخصیِ پزشک را می‌دید. آدرسِ درست + * روی خودِ نوبت ثبت شده (`address_id`) و همان‌جا برای مطب و کلینیک یکسان کار می‌کند. + * + * شماره هم اینجا برمی‌گردد و نه در پاسخ‌های عمومی: مخاطبِ این متد یا صاحب نوبت است + * یا کسی که `canView()` تأییدش کرده. اگر خودِ آدرس شماره نداشته باشد، شمارهٔ کلینیکِ + * همان نوبت جایش می‌نشیند تا بیمار بی‌راه‌ارتباطی نماند. + */ + private function appointmentWithVenue(Appointment $appointment): array + { + $payload = $appointment->toArray(); + + $addressId = $appointment->getAddressId(); + $address = $addressId !== null ? $this->addressRepo->find($addressId) : null; + + if ($address !== null) { + $payload['address'] = $address->toArray(null, true); + } elseif ($payload['address'] !== null) { + // نوبت‌های قدیمی `address_id` ندارند؛ همان آدرسِ حدسیِ toArray() می‌ماند، + // ولی دست‌کم بدون شمارهٔ اشتباه نمایش داده نمی‌شود. + $payload['address']['telephone'] = null; + } + + if (($payload['address']['telephone'] ?? null) === null && $payload['address'] !== null) { + $clinic = $appointment->getClinic(); + if ($clinic !== null) { + $payload['address']['telephone'] = $clinic->getTelephone(); + } + } + + return $payload; + } + private function canView(Appointment $a, User $user): bool { return $this->accessChecker->canView($a, $user); diff --git a/src/Clinic/Controller/ClinicController.php b/src/Clinic/Controller/ClinicController.php index 8adac218..be9b0569 100644 --- a/src/Clinic/Controller/ClinicController.php +++ b/src/Clinic/Controller/ClinicController.php @@ -131,7 +131,7 @@ class ClinicController extends BaseController $this->userRepo->save($user); } - return $this->success(['data' => $clinic->toDetailArray()], 201); + return $this->success(['data' => $clinic->toDetailArray(withContact: true)], 201); } #[OA\Get( @@ -177,7 +177,7 @@ class ClinicController extends BaseController ); return $this->success(['data' => array_merge( - $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone), + $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone, $canEdit), ['can_edit' => $canEdit], )]); } @@ -277,7 +277,7 @@ class ClinicController extends BaseController [$stateData, $cityData, $map, $street, $telephone] = $this->loadLocationData($clinic); - return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone)]); + return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData, $map, $street, $telephone, true)]); } #[OA\Get( @@ -795,7 +795,7 @@ class ClinicController extends BaseController $this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), $data); } - return $this->success(['data' => $address->toArray()], 201); + return $this->success(['data' => $address->toArray(null, true)], 201); } #[Route('/api/v1/clinic/{clinicUuid}/address/{addressUuid}', methods: ['PATCH'])] @@ -824,7 +824,7 @@ class ClinicController extends BaseController $this->editLogger->logEdit($user, 'clinic', $clinic->getUuid(), $data); } - return $this->success(['data' => $address->toArray()]); + return $this->success(['data' => $address->toArray(null, true)]); } #[Route('/api/v1/clinic/{clinicUuid}/address/{addressUuid}', methods: ['DELETE'])] diff --git a/src/Clinic/Entity/Clinic.php b/src/Clinic/Entity/Clinic.php index 1c6dd536..dbb0f693 100644 --- a/src/Clinic/Entity/Clinic.php +++ b/src/Clinic/Entity/Clinic.php @@ -209,9 +209,11 @@ class Clinic array $cityData = [], ?array $map = null, ?string $street = null, - ?string $telephone = null + ?string $telephone = null, + bool $withContact = false ): array { - $phone = $this->firstFilled($telephone, $this->telephone); + // شمارهٔ کلینیک هم مثل شمارهٔ مطب عمومی نیست. {@see DoctorAddress::toArray()} + $phone = $withContact ? $this->firstFilled($telephone, $this->telephone) : null; $address = $this->firstFilled($street, $this->address); return [ @@ -267,9 +269,9 @@ class Clinic return null; } - public function toListArray(?string $city = null, ?string $state = null, ?string $telephone = null): array + public function toListArray(?string $city = null, ?string $state = null, ?string $telephone = null, bool $withContact = false): array { - $phone = $this->firstFilled($telephone, $this->telephone); + $phone = $withContact ? $this->firstFilled($telephone, $this->telephone) : null; return [ 'id' => (string) $this->id, diff --git a/src/Doctor/Controller/AddressController.php b/src/Doctor/Controller/AddressController.php index f19700ea..4ccf0c71 100644 --- a/src/Doctor/Controller/AddressController.php +++ b/src/Doctor/Controller/AddressController.php @@ -41,7 +41,8 @@ class AddressController extends BaseController $this->clinicDoctorAccess->denyUnlessGranted($user, 'addresses', 'view'); return $this->success(array_map( - static fn (DoctorAddress $a): array => $a->toArray(), + // فهرست محیطِ خودِ کاربر است، پس شمارهٔ تماس هم برمی‌گردد. + static fn (DoctorAddress $a): array => $a->toArray(null, true), $this->addresses->listForContext($user), )); } diff --git a/src/Doctor/Controller/DoctorController.php b/src/Doctor/Controller/DoctorController.php index 904acf5a..87b54820 100644 --- a/src/Doctor/Controller/DoctorController.php +++ b/src/Doctor/Controller/DoctorController.php @@ -127,7 +127,7 @@ class DoctorController extends BaseController $this->userRepo->save($user); } - return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))], 201); + return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor), true)], 201); } #[OA\Get( @@ -159,12 +159,14 @@ class DoctorController extends BaseController } $clinics = $this->clinicRepo->findByDoctor($doctor); + // شمارهٔ کلینیک بعد از محاسبهٔ can_edit اضافه می‌شود؛ اینجا هنوز معلوم نیست + // درخواست‌دهنده صاحب پروفایل است یا یک بازدیدکنندهٔ عمومی. $clinicData = array_map(fn(Clinic $c) => [ 'id' => (string) $c->getId(), 'uuid' => $c->getUuid(), 'name' => $c->getName(), 'address' => $c->getAddress(), - 'telephone' => $c->getTelephone(), + 'telephone' => null, 'city_id' => $c->getCityId(), 'province_id' => $c->getProvinceId(), 'map' => [ @@ -187,8 +189,14 @@ class DoctorController extends BaseController || $this->editPolicy->ownsDoctor($user, $doctor) ); + if ($canEdit) { + foreach ($clinics as $index => $clinic) { + $clinicData[$index]['telephone'] = $clinic->getTelephone(); + } + } + $schedules = $this->scheduleRepo->findAllByDoctor($doctor); - return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), [ + return $this->success(['data' => array_merge($doctor->toDetailArray($schedules, $canEdit), [ 'clinics' => $clinicData, 'representation' => $representation, 'can_edit' => $canEdit, @@ -214,7 +222,7 @@ class DoctorController extends BaseController } $schedules = $this->scheduleRepo->findAllByDoctor($doctor); - return $this->success(['data' => array_merge($doctor->toDetailArray($schedules), ['clinics' => [[ + return $this->success(['data' => array_merge($doctor->toDetailArray($schedules, true), ['clinics' => [[ 'id' => (string) $clinic->getId(), 'uuid' => $clinic->getUuid(), 'name' => $clinic->getName(), @@ -393,7 +401,7 @@ class DoctorController extends BaseController $this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data); } - return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor))]); + return $this->success(['data' => $doctor->toDetailArray($this->scheduleRepo->findAllByDoctor($doctor), true)]); } #[OA\Delete( @@ -613,7 +621,7 @@ class DoctorController extends BaseController $this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data); } - return $this->success(['data' => $address->toArray()], 201); + return $this->success(['data' => $address->toArray(null, true)], 201); } #[OA\Get( @@ -651,7 +659,7 @@ class DoctorController extends BaseController return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } - return $this->success(['data' => $address->toArray()]); + return $this->success(['data' => $address->toArray(null, true)]); } #[OA\Patch( @@ -715,7 +723,7 @@ class DoctorController extends BaseController $this->editLogger->logEdit($user, 'doctor', $doctor->getUuid(), $data); } - return $this->success(['data' => $address->toArray()]); + return $this->success(['data' => $address->toArray(null, true)]); } #[OA\Delete( diff --git a/src/Doctor/Entity/Doctor.php b/src/Doctor/Entity/Doctor.php index 814151b6..f2b31398 100644 --- a/src/Doctor/Entity/Doctor.php +++ b/src/Doctor/Entity/Doctor.php @@ -601,7 +601,11 @@ class Doctor } /** @param WeeklySchedule[] $schedules همهٔ برنامه‌های پزشک (شخصی + کلینیک‌ها) */ - public function toDetailArray(array $schedules = []): array + /** + * `$withContact` فقط برای مخاطبی روشن می‌شود که حق دیدن شمارهٔ مطب را دارد — + * صاحب پروفایل یا ادمین. پاسخ عمومی بدون شماره است. {@see DoctorAddress::toArray()} + */ + public function toDetailArray(array $schedules = [], bool $withContact = false): array { $sf = $this->computeScheduleFields($schedules); return [ @@ -638,7 +642,7 @@ class Doctor 'owner_status' => $this->ownerStatus, 'free_turn' => $sf['free_turn'], 'hours_of_work' => $sf['hours_of_work'], - 'address' => array_map(fn(DoctorAddress $a) => $a->toArray(), $this->addresses->toArray()), + 'address' => array_map(fn(DoctorAddress $a) => $a->toArray(null, $withContact), $this->addresses->toArray()), 'average_rate' => ['total_rates' => null], 'state' => array_map(fn(Province $p) => [ 'uuid' => $p->getUuid(), diff --git a/src/Doctor/Entity/DoctorAddress.php b/src/Doctor/Entity/DoctorAddress.php index 59c5ec8c..14560cea 100644 --- a/src/Doctor/Entity/DoctorAddress.php +++ b/src/Doctor/Entity/DoctorAddress.php @@ -151,7 +151,16 @@ class DoctorAddress private function touch(): void { $this->updatedAt = time(); } - public function toArray(?string $clinicName = null): array + /** + * شمارهٔ تماسِ محل، دادهٔ عمومی نیست. + * + * صفحهٔ پزشک شمارهٔ مطب را کنار آدرس نشان می‌داد و همان شماره در پاسخ عمومیِ API + * هم می‌آمد — یعنی برداشت انبوهِ شمارهٔ همهٔ مطب‌ها فقط یک درخواست فاصله داشت. + * حالا پیش‌فرض حذف است و فقط جایی که مخاطب حق دیدنش را دارد — صاحب همان محل، یا + * بیماری که نوبتش آنجاست — با `$withContact` برمی‌گردد. موقعیت مکانی و آدرس + * عمومی می‌مانند؛ آن‌ها همان چیزی‌اند که بیمار برای پیدا کردن مطب لازم دارد. + */ + public function toArray(?string $clinicName = null, bool $withContact = false): array { return [ 'id' => (string) $this->id, @@ -165,7 +174,7 @@ class DoctorAddress 'longitude' => $this->longitude !== null ? (string) $this->longitude : null, ], 'address' => $this->address, - 'telephone' => $this->telephone, + 'telephone' => $withContact ? $this->telephone : null, 'active' => $this->active, 'timezone' => $this->timezone, 'city' => $this->city !== null ? [ diff --git a/tests/Clinic/ClinicContactSourceTest.php b/tests/Clinic/ClinicContactSourceTest.php index c9ea33ad..99847637 100644 --- a/tests/Clinic/ClinicContactSourceTest.php +++ b/tests/Clinic/ClinicContactSourceTest.php @@ -15,6 +15,10 @@ use App\Tests\ApiTestCase; * A clinic whose address record said Karaj then rendered a Yazd street on the * public site. Contact fields must all resolve from the address record, and only * fall back to the legacy columns when that record has nothing to offer. + * + * The phone is a separate matter: it is not public data, so the anonymous + * response must never carry it. The resolution order is still asserted, just + * through the owner's view of the same endpoint. */ class ClinicContactSourceTest extends ApiTestCase { @@ -26,10 +30,23 @@ class ClinicContactSourceTest extends ApiTestCase $data = $this->fetchClinic($clinic); $this->assertSame('کرج، بلوار طالقانی', $data['location']); - $this->assertSame('02634567890', $data['phone']); - $this->assertSame('02634567890', $data['phone_number']); $this->assertSame('کرج', $data['city'][0]['name'] ?? null); $this->assertSame('البرز', $data['state'][0]['name'] ?? null); + + $owner = $this->fetchClinicAsOwner($clinic); + $this->assertSame('02634567890', $owner['phone']); + $this->assertSame('02634567890', $owner['phone_number']); + } + + public function testThePublicResponseCarriesNoPhoneNumber(): void + { + $clinic = $this->createClinic('یزد، خیابان قدیمی', '03531234567'); + $this->createAddressFor($clinic, 'کرج، بلوار طالقانی', '02634567890'); + + $data = $this->fetchClinic($clinic); + + $this->assertNull($data['phone'], 'شمارهٔ کلینیک نباید در پاسخ عمومی بیاید'); + $this->assertNull($data['phone_number']); } public function testLegacyColumnsAreUsedWhenTheClinicHasNoAddressRecord(): void @@ -39,8 +56,8 @@ class ClinicContactSourceTest extends ApiTestCase $data = $this->fetchClinic($clinic); $this->assertSame('یزد، خیابان قدیمی', $data['location']); - $this->assertSame('03531234567', $data['phone']); $this->assertSame([], $data['city']); + $this->assertSame('03531234567', $this->fetchClinicAsOwner($clinic)['phone']); } public function testBlankAddressFieldsFallBackInsteadOfBlankingTheContactBlock(): void @@ -51,8 +68,8 @@ class ClinicContactSourceTest extends ApiTestCase $data = $this->fetchClinic($clinic); $this->assertSame('یزد، خیابان قدیمی', $data['location']); - $this->assertSame('03531234567', $data['phone']); $this->assertSame('کرج', $data['city'][0]['name'] ?? null); + $this->assertSame('03531234567', $this->fetchClinicAsOwner($clinic)['phone']); } private function createClinic(?string $legacyAddress, ?string $legacyPhone): Clinic @@ -92,4 +109,13 @@ class ClinicContactSourceTest extends ApiTestCase return json_decode($this->client->getResponse()->getContent(), true)['data']['data']; } + + /** همان اندپوینت، این‌بار با توکنِ صاحب کلینیک — جایی که شماره دیده می‌شود. */ + private function fetchClinicAsOwner(Clinic $clinic): array + { + $body = $this->authJson('GET', '/api/v1/clinic/' . $clinic->getUuid(), $clinic->getUser()); + $this->assertSame(200, $this->responseCode()); + + return $body['data']['data']; + } } diff --git a/tests/Doctor/AddressPhoneIsNotPublicTest.php b/tests/Doctor/AddressPhoneIsNotPublicTest.php new file mode 100644 index 00000000..0b579873 --- /dev/null +++ b/tests/Doctor/AddressPhoneIsNotPublicTest.php @@ -0,0 +1,111 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($user, 'دکتر تماس'); + $this->em->persist($doctor); + + $address = DoctorAddress::forDoctor($doctor) + ->setName('مطب مرکزی') + ->setAddress('یزد، خیابان کاشانی') + ->setTelephone($phone); + $this->em->persist($address); + $this->em->flush(); + + // درخواستِ کرنل همین EM را می‌بیند و کالکشنِ آدرس‌های این نمونه از لحظهٔ + // ساخت خالی مانده است؛ بدون clear، پاسخ آدرسی نشان نمی‌دهد. + $this->em->clear(); + + return [ + $this->em->getRepository(Doctor::class)->find($doctor->getId()), + $this->em->getRepository(DoctorAddress::class)->find($address->getId()), + $this->em->getRepository(\App\Auth\Entity\User::class)->find($user->getId()), + ]; + } + + public function testPublicDoctorResponseKeepsTheAddressButDropsThePhone(): void + { + [$doctor, , ] = $this->doctorWithAddress(); + + $this->client->request('GET', '/api/v1/doctor/' . $doctor->getUuid()); + $this->assertSame(200, $this->responseCode()); + + $payload = json_decode($this->client->getResponse()->getContent(), true); + $address = $payload['data']['data']['address'][0]; + + $this->assertSame('یزد، خیابان کاشانی', $address['address'], 'آدرس باید عمومی بماند'); + $this->assertNull($address['telephone'], 'شمارهٔ مطب نباید عمومی باشد'); + } + + public function testTheDoctorStillSeesTheirOwnPhone(): void + { + [$doctor, , $owner] = $this->doctorWithAddress(); + + $body = $this->authJson('GET', '/api/v1/doctor/' . $doctor->getUuid(), $owner); + + $this->assertSame('03536666666', $body['data']['data']['address'][0]['telephone']); + } + + public function testThePatientSeesTheVenuePhoneOnTheirOwnAppointment(): void + { + [$doctor, $address, ] = $this->doctorWithAddress(); + $patient = $this->createUser(['ROLE_USER']); + + $appointment = $this->newAppointment($doctor, $patient, time() + 3600, time() + 5400); + $appointment->setAddressId((int) $address->getId()); + $this->em->persist($appointment); + $this->em->flush(); + + $body = $this->authJson('GET', '/api/v1/appointments/user', $patient); + $row = $body['data']['data'][0]; + + $this->assertSame($appointment->getUuid(), $row['uuid']); + $this->assertSame('یزد، خیابان کاشانی', $row['address']['address']); + $this->assertSame('03536666666', $row['address']['telephone']); + } + + /** + * پیش از این نشانی نوبت «اولین آدرس پزشک» بود، پس بیمارِ شعبهٔ دوم نشانی و شمارهٔ + * شعبهٔ اول را می‌دید — نه فقط ناقص، که نشانیِ اشتباه. + */ + public function testTheAppointmentShowsItsOwnVenueNotTheDoctorsFirstOne(): void + { + [$doctor, , ] = $this->doctorWithAddress('03511111111'); + + $second = DoctorAddress::forDoctor($doctor) + ->setName('مطب دوم') + ->setAddress('یزد، صفائیه') + ->setTelephone('03522222222'); + $this->em->persist($second); + $this->em->flush(); + + $patient = $this->createUser(['ROLE_USER']); + $appointment = $this->newAppointment($doctor, $patient, time() + 3600, time() + 5400); + $appointment->setAddressId((int) $second->getId()); + $this->em->persist($appointment); + $this->em->flush(); + + $row = $this->authJson('GET', '/api/v1/appointments/user', $patient)['data']['data'][0]; + + $this->assertSame('یزد، صفائیه', $row['address']['address']); + $this->assertSame('03522222222', $row['address']['telephone']); + } +}