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>
110 lines
5.4 KiB
PHP
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);
|
|
}
|
|
}
|