Phase 7 was scoped to guard aggregate children, which the Doctrine filter cannot reach. Measuring first — as the plan required — moved the target: all 22 children and their 20 repositories were already sound. Every list query anchors on its root, and ServiceItemRepository even joins service_sections and filters on the pair by hand. A repository-level guard would have found nothing. The real exposure was one layer up. Where a uuid arrives from a request body or query string, the entity it names is loaded by uuid alone, and the filter is no help: aggregate children have no tenant column, and a panel user who never chose an environment is not filtered at all. Three leaks, each proven by removing the fix and watching the new tests go red: - GET /api/v1/appointment-service-slots accepted service_item_uuids from any environment. Existence, bookable state and duration leaked through the error messages and the returned slots. The booking path in the same controller had guarded this since it was written; the slot path never did. - POST /api/v1/my/appointment attached service_section_uuid, service_item_uuid, staff_uuid and the service list without any check, and persisted them onto the appointment. A write, not just a read. - PatientService did the same in all three of its loops — pricing, session create, session update — so another environment's service price entered the invoice and its SessionService row was stored, staff included. TenantOwnershipChecker is the single place that answers "does this belong to the current environment?". It reads getEntityType()/getEntityId(), so ServiceItem now delegates that pair to its section: an aggregate child exposing the tenant it inherits. An entity that exposes no pair throws rather than returning false — silence here builds an always-closed guard, which is its own bug. TenantLookupInventoryTest keeps a per-file count of these lookups. It earned its place immediately: the first run found more sites than the manual grep had, and reviewing them turned up the third PatientService loop. StaffController looked unguarded until read properly — ownsStaff sits two lines below the null check. One assertion was wrong before it was right: the create-path test read `$session['services'] ?? []`, which passes vacuously. It now counts the stored rows through the repository, and fails without the fix. Tests: 879 passing. PHPStan unchanged at its 17 pre-existing errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
282 lines
13 KiB
PHP
282 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Appointment;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Appointment\Entity\WeeklySchedule;
|
|
use App\ClinicService\Entity\ServiceItem;
|
|
use App\ClinicService\Entity\ServiceSection;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* حالت نوبتدهی سرویسی: پاسخ booking-services باید بخش هر سرویس را بدهد؛ اسلاتها و
|
|
* ثبت نوبت باید مدتِ override منشی را (فقط برای همان نوبت) لحاظ کنند بدون تغییر پیشفرضِ سرویس.
|
|
*/
|
|
class ServiceModeSectionDurationTest extends ApiTestCase
|
|
{
|
|
/** @return array{0:\App\Auth\Entity\User,1:Doctor,2:string} */
|
|
private function serviceDoctor(int $serviceMinutes = 30): array
|
|
{
|
|
$owner = $this->createUser(['ROLE_DOCTOR']);
|
|
$doctor = new Doctor($owner, 'دکتر سرویس');
|
|
$this->em->persist($doctor);
|
|
|
|
$date = date('Y-m-d', strtotime('tomorrow'));
|
|
$dayKey = (string) (((int) date('w', strtotime($date)) + 1) % 7);
|
|
$schedule = $this->newWeeklySchedule($doctor, [
|
|
$dayKey => ['sessions' => [[
|
|
'active' => true, 'start_time' => '15:00', 'end_time' => '19:00',
|
|
'duration_per_patient' => 20, 'location_id' => 1,
|
|
]]],
|
|
]);
|
|
$schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => 0]);
|
|
$this->em->persist($schedule);
|
|
$this->em->flush();
|
|
|
|
return [$owner, $doctor, $date];
|
|
}
|
|
|
|
private function service(Doctor $doctor, string $name, int $minutes): ServiceItem
|
|
{
|
|
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name);
|
|
$this->em->persist($section);
|
|
$item = new ServiceItem($section, $name, 0);
|
|
$item->setDurationMinutes($minutes)->setBookable(true);
|
|
$this->em->persist($item);
|
|
$this->em->flush();
|
|
|
|
return $item;
|
|
}
|
|
|
|
public function testBookingServicesReturnsSection(): void
|
|
{
|
|
[$owner, $doctor] = $this->serviceDoctor();
|
|
$svc = $this->service($doctor, 'بوتاکس', 30);
|
|
|
|
$res = $this->authJson('GET', '/api/v1/appointment-booking-services/' . $doctor->getUuid(), $owner);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$row = $res['data']['services'][0];
|
|
self::assertSame($svc->getUuid(), $row['uuid']);
|
|
self::assertArrayHasKey('service_section', $row);
|
|
self::assertSame($svc->getSection()->getUuid(), $row['service_section']['uuid']);
|
|
self::assertSame($svc->getSection()->getName(), $row['service_section']['name']);
|
|
}
|
|
|
|
public function testServiceSlotsHonorsDurationOverride(): void
|
|
{
|
|
[$owner, $doctor, $date] = $this->serviceDoctor();
|
|
$svc = $this->service($doctor, 'فیلر', 30);
|
|
|
|
// بدون override: مدت کل = ۳۰
|
|
$base = $this->authJson('GET', sprintf(
|
|
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s',
|
|
$doctor->getUuid(), $date, $svc->getUuid()
|
|
), $owner);
|
|
self::assertSame(30, $base['data']['total_duration_minutes']);
|
|
|
|
// با override = ۹۰
|
|
$over = $this->authJson('GET', sprintf(
|
|
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s&durations[%s]=90',
|
|
$doctor->getUuid(), $date, $svc->getUuid(), $svc->getUuid()
|
|
), $owner);
|
|
self::assertSame(90, $over['data']['total_duration_minutes']);
|
|
|
|
// پیشفرضِ سرویس در DB تغییر نکرده
|
|
$this->em->clear();
|
|
$reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $svc->getUuid()]);
|
|
self::assertSame(30, $reloaded->getDurationMinutes());
|
|
}
|
|
|
|
public function testCreateAppliesDurationOverrideToSlotEnd(): void
|
|
{
|
|
[$owner, $doctor] = $this->serviceDoctor();
|
|
$svc = $this->service($doctor, 'لیزر', 30);
|
|
|
|
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
|
$nc = '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT);
|
|
|
|
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
|
'doctor_uuid' => $doctor->getUuid(),
|
|
'slot_start' => $start,
|
|
'slot_end' => $start + 60, // نادیده گرفته میشود (بازمحاسبه)
|
|
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
|
'patient_name' => 'بیمار تست',
|
|
'patient_national_code' => $nc,
|
|
'service_item_uuids' => [$svc->getUuid()],
|
|
'duration_from_services' => true,
|
|
'service_durations' => [$svc->getUuid() => 75],
|
|
]);
|
|
self::assertSame(201, $this->responseCode());
|
|
|
|
$this->em->clear();
|
|
$appt = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $res['data']['uuid']]);
|
|
// slot_end = start + 75 دقیقه (override)، نه ۳۰ پیشفرض
|
|
self::assertSame($start + 75 * 60, $appt->getSlotEnd());
|
|
|
|
// پیشفرضِ سرویس دستنخورده
|
|
$reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $svc->getUuid()]);
|
|
self::assertSame(30, $reloaded->getDurationMinutes());
|
|
}
|
|
|
|
// ── جداسازی محیط روی سرویسهای انتخابی ──────────────────────────────────
|
|
|
|
/**
|
|
* سرویسها فرزند aggregateاند (service_items → service_sections) و ستون محیط
|
|
* ندارند، پس TenantFilter پوششان نمیدهد. مسیر ثبت نوبت با
|
|
* assertServicesMatchContext محافظت میشد ولی مسیر محاسبهٔ اسلات نه — با uuid
|
|
* سرویسِ محیط دیگر میشد وجود، فعالبودن و مدتش را استنتاج کرد.
|
|
*/
|
|
public function testServiceSlotsRejectsAServiceFromAnotherEnvironment(): void
|
|
{
|
|
[$owner, $doctor, $date] = $this->serviceDoctor();
|
|
[, $stranger] = $this->serviceDoctor();
|
|
$foreign = $this->service($stranger, 'سرویس بیگانه', 45);
|
|
|
|
$this->authJson('GET', sprintf(
|
|
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s',
|
|
$doctor->getUuid(), $date, $foreign->getUuid(),
|
|
), $owner);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
|
|
/** حتی وقتی یک سرویسِ خودی هم در فهرست است، سرویسِ بیگانه کل درخواست را رد میکند. */
|
|
public function testOneForeignServiceInvalidatesTheWholeRequest(): void
|
|
{
|
|
[$owner, $doctor, $date] = $this->serviceDoctor();
|
|
$mine = $this->service($doctor, 'سرویس خودی', 30);
|
|
[, $stranger] = $this->serviceDoctor();
|
|
$foreign = $this->service($stranger, 'سرویس بیگانه', 45);
|
|
|
|
$this->authJson('GET', sprintf(
|
|
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s&service_item_uuids[]=%s',
|
|
$doctor->getUuid(), $date, $mine->getUuid(), $foreign->getUuid(),
|
|
), $owner);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
|
|
/** ⚠️ مسیر سالم نباید بشکند: سرویس همان پزشک همچنان اسلات میدهد. */
|
|
public function testServiceSlotsStillWorkForTheDoctorsOwnService(): void
|
|
{
|
|
[$owner, $doctor, $date] = $this->serviceDoctor();
|
|
$mine = $this->service($doctor, 'سرویس خودی', 30);
|
|
|
|
$res = $this->authJson('GET', sprintf(
|
|
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s',
|
|
$doctor->getUuid(), $date, $mine->getUuid(),
|
|
), $owner);
|
|
|
|
self::assertSame(200, $this->responseCode());
|
|
self::assertSame(30, $res['data']['total_duration_minutes']);
|
|
self::assertNotEmpty($res['data']['start_times']);
|
|
}
|
|
|
|
/**
|
|
* نشتیِ نوشتنی: مسیر ثبت نوبت از پنل، بخش/سرویس/پرسنل را با uuid از بدنهٔ
|
|
* درخواست میگرفت و بدون بررسی محیط به نوبت میچسباند — یعنی دادهٔ محیط دیگری
|
|
* ذخیره میشد، نه فقط خوانده.
|
|
*/
|
|
public function testPanelBookingRejectsAServiceFromAnotherEnvironment(): void
|
|
{
|
|
[$owner, $doctor] = $this->serviceDoctor();
|
|
[, $stranger] = $this->serviceDoctor();
|
|
$foreign = $this->service($stranger, 'سرویس بیگانه', 30);
|
|
|
|
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
|
|
|
$this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
|
'doctor_uuid' => $doctor->getUuid(),
|
|
'slot_start' => $start,
|
|
'slot_end' => $start + 1_800,
|
|
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
|
'patient_name' => 'بیمار تست',
|
|
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
|
|
'service_item_uuids' => [$foreign->getUuid()],
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
|
|
/** همان نشتی از مسیر service_item_uuid تکی (فیلد workflow کلینیک). */
|
|
public function testPanelBookingRejectsAForeignServiceItemField(): void
|
|
{
|
|
[$owner, $doctor] = $this->serviceDoctor();
|
|
[, $stranger] = $this->serviceDoctor();
|
|
$foreign = $this->service($stranger, 'سرویس بیگانه', 30);
|
|
|
|
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
|
|
|
$this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
|
'doctor_uuid' => $doctor->getUuid(),
|
|
'slot_start' => $start,
|
|
'slot_end' => $start + 1_800,
|
|
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
|
'patient_name' => 'بیمار تست',
|
|
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
|
|
'service_item_uuid' => $foreign->getUuid(),
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
|
|
/** و بخشِ محیط دیگر هم رد میشود. */
|
|
public function testPanelBookingRejectsAForeignServiceSection(): void
|
|
{
|
|
[$owner, $doctor] = $this->serviceDoctor();
|
|
[, $stranger] = $this->serviceDoctor();
|
|
$foreignSection = $this->service($stranger, 'سرویس بیگانه', 30)->getSection();
|
|
|
|
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
|
|
|
$this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
|
'doctor_uuid' => $doctor->getUuid(),
|
|
'slot_start' => $start,
|
|
'slot_end' => $start + 1_800,
|
|
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
|
'patient_name' => 'بیمار تست',
|
|
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
|
|
'service_section_uuid' => $foreignSection->getUuid(),
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
|
|
/** ⚠️ مسیر سالم نباید بشکند: سرویس خودی همچنان ثبت میشود. */
|
|
public function testPanelBookingStillAcceptsTheDoctorsOwnService(): void
|
|
{
|
|
[$owner, $doctor] = $this->serviceDoctor();
|
|
$mine = $this->service($doctor, 'سرویس خودی', 30);
|
|
|
|
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
|
|
|
$this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
|
'doctor_uuid' => $doctor->getUuid(),
|
|
'slot_start' => $start,
|
|
'slot_end' => $start + 1_800,
|
|
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
|
'patient_name' => 'بیمار تست',
|
|
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
|
|
'service_item_uuids' => [$mine->getUuid()],
|
|
'service_item_uuid' => $mine->getUuid(),
|
|
]);
|
|
|
|
self::assertSame(201, $this->responseCode());
|
|
}
|
|
|
|
/** uuid کاملاً ناموجود هم همان ۴۲۲ را میگیرد — پیام «وجود ندارد» جدا لو نمیرود. */
|
|
public function testUnknownServiceUuidIsIndistinguishableFromAForeignOne(): void
|
|
{
|
|
[$owner, $doctor, $date] = $this->serviceDoctor();
|
|
|
|
$this->authJson('GET', sprintf(
|
|
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s',
|
|
$doctor->getUuid(), $date, '00000000-0000-4000-8000-000000000000',
|
|
), $owner);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
}
|