feat(privacy): keep venue phone numbers out of every public response
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Doctor;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* شمارهٔ تماس مطب دادهٔ عمومی نیست.
|
||||
*
|
||||
* صفحهٔ عمومی پزشک آن را کنار آدرس نشان میداد و پاسخ عمومی API هم همان را داشت،
|
||||
* پس جمعآوری انبوهِ شمارهٔ مطبها یک درخواست بینام فاصله داشت. آدرس و موقعیت
|
||||
* عمومی میمانند — بیمار برای پیدا کردن مطب به آنها نیاز دارد — ولی شماره فقط به
|
||||
* صاحب پروفایل و به بیماری که نوبتش همانجاست نشان داده میشود.
|
||||
*/
|
||||
class AddressPhoneIsNotPublicTest extends ApiTestCase
|
||||
{
|
||||
private function doctorWithAddress(string $phone = '03536666666'): array
|
||||
{
|
||||
$user = $this->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user