Files
clinicpro/tests/Shared/TenantLookupInventoryTest.php
T
hamedandClaude Opus 5 03f09637ed fix(booking): store the services and duration a public booking was made with
POST /api/v1/appointment resolved the selected services, summed their minutes,
used that to compute slot_end — and then dropped the result. It never called
replaceServiceItems() or setServiceDuration(), so an appointment booked from
the public site kept no record of what it was booked for:

- the patient panel showed neither the service nor the duration
- reports counted the appointment as having no services
- a later reschedule had no duration to preserve

The management path did all of this correctly; only the public path did not.
Found by booking through the real endpoint and looking at the panel, which is
the one thing no test did.

The duration was also computed as a naive sum of duration_minutes, ignoring the
solo/additional split. That made a multi-service booking's length disagree with
the slots appointment-service-slots had just offered the patient — the booking
would occupy a different span than the one shown. Both paths now go through
ServiceBookingCalculator, which is what builds those slots.

For data that only sets duration_minutes, the calculator returns the same total
as the old sum, so existing services are unaffected.

assertServicesMatchContext() is gone: the calculator performs the identical
ownership check with the same error code and message, and the tenant-lookup
inventory is updated to match.

Tests: PublicBookingServicePersistenceTest starts at the endpoint rather than
building an appointment in memory — the gap that let this ship. Verified it
fails (4 of 8) with the fix disabled. Full suite 1433 green, slot-mode-frozen
green, phpstan at its 14-error baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:43:23 +03:30

110 lines
5.4 KiB
PHP

<?php
namespace App\Tests\Shared;
use PHPUnit\Framework\TestCase;
/**
* فهرستِ بازبینی‌شدهٔ جست‌وجوهای «uuid از درخواست → موجودیت محیط‌دار».
*
* سه نشتی این فاز همگی همین شکل را داشتند و هیچ‌کدام در repository نبودند؛ در
* کنترلر و سرویس بودند، جایی که uuid از بدنه/کوئری می‌آید و کسی محیط را نمی‌سنجد.
* پس گارد هم باید همان‌جا باشد، نه روی DQLهای repository.
*
* قاعده: عدد هر فایل = تعداد findByUuid روی repositoryهای محیط‌دار در آن فایل، که
* همگی بازبینی شده‌اند. اگر عدد عوض شود یعنی جست‌وجوی تازه‌ای اضافه شده و باید
* ثابت شود محیطش بررسی می‌شود — با ownsRecord/ownsSection/ownedItem،
* TenantOwnershipChecker، یا لنگرزدن به ریشه‌ای که خودش بررسی شده.
*
* @see \App\Shared\Tenant\TenantOwnershipChecker
* @see docs/architecture/tenancy.md
*/
class TenantLookupInventoryTest extends TestCase
{
/** repositoryهایی که موجودیت محیط‌دار یا فرزند aggregate برمی‌گردانند. */
private const TENANT_REPOSITORY_VARIABLES = [
'itemRepo', 'serviceItemRepo', 'sectionRepo', 'staffRepo',
'noteRepo', 'attachmentRepo', 'callRepo', 'messageRepo', 'medicalRepo',
'sessionRepo', 'sessionPaymentRepo', 'recordRepo',
'tenantInsuranceRepo', 'tenantTagRepo', 'packageRepo', 'ruleRepo',
];
/**
* فایل => تعداد جست‌وجوی بازبینی‌شده.
*
* @var array<string, int>
*/
private const REVIEWED = [
// AppointmentController دیگر جست‌وجوی بازبینی‌نشده ندارد: assertServicesMatchContext
// حذف شد و مسیر ثبت نوبت هم مثل مسیر اسلات از ServiceBookingCalculator رد می‌شود،
// که خودش مالکیت محیط را می‌سنجد و پایین‌تر شمرده می‌شود.
// TenantOwnershipChecker::belongsToPair() روی هر uuid، پیش از هر بررسی دیگر
'src/Appointment/Service/ServiceBookingCalculator.php' => 2,
// TenantOwnershipChecker روی بخش/سرویس/پرسنل و فهرست سرویس‌ها
'src/Appointment/Controller/MyAppointmentsController.php' => 1,
// ownsSession / getEntityType روی صورتحساب، پرونده و مطالبه
'src/Billing/Controller/BillingController.php' => 3,
// ownsSection ×۹ و مقایسهٔ مستقیم جفت ×۳ (پکیج، کالا، پرسنل)
'src/ClinicService/Controller/ClinicServiceController.php' => 10,
'src/Discount/Controller/DiscountController.php' => 1,
// قرارداد بیمه با جفت، و سرویس با getSection()->getEntityType()
'src/Insurance/Controller/InsuranceController.php' => 5,
// ownedItem() / ownedPackage()
'src/Inventory/Controller/InventoryController.php' => 2,
'src/Inventory/Service/InventoryService.php' => 1,
// ownsRecord ×۳۳ و دو پرداختِ لنگرخورده به مراجعهٔ بررسی‌شده
'src/Patient/Controller/PatientController.php' => 35,
// TenantOwnershipChecker در هر سه حلقه (قیمت‌گذاری، ساخت، ویرایش) + کالا
'src/Patient/Service/PatientService.php' => 5,
// ownsStaff()
'src/Staff/Controller/StaffController.php' => 2,
];
private function projectDir(): string
{
return dirname(__DIR__, 2);
}
/** @return array<string, int> */
private function countLookups(): array
{
$pattern = '/->(' . implode('|', self::TENANT_REPOSITORY_VARIABLES) . ')->findByUuid\(/';
$counts = [];
$files = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->projectDir() . '/src', \FilesystemIterator::SKIP_DOTS),
);
foreach ($files as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$found = preg_match_all($pattern, (string) file_get_contents($file->getPathname()));
if ($found > 0) {
$relative = str_replace($this->projectDir() . '/', '', $file->getPathname());
$counts[$relative] = $found;
}
}
ksort($counts);
return $counts;
}
public function testNoUnreviewedTenantLookupExists(): void
{
$actual = $this->countLookups();
$expected = self::REVIEWED;
ksort($expected);
self::assertSame($expected, $actual, <<<'MSG'
جست‌وجوی «uuid از درخواست موجودیت محیط‌دار» تغییر کرده.
برای هر مورد تازه ثابت کن محیطش بررسی می‌شود (ownsRecord/ownsSection/
ownedItem، TenantOwnershipChecker، یا لنگر به ریشهٔ بررسی‌شده) و بعد عدد
همان فایل را در REVIEWED به‌روز کن. اگر عدد کم شده، فقط عدد را کم کن.
MSG);
}
}