From d53874ff50d883f6dbe96f01368644f49246576a Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 28 Jul 2026 11:22:37 +0330 Subject: [PATCH] feat(tenant): mark the booking tables with their owning environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the tenant-marking series. appointments, weekly_schedules and date_overrides kept their environment implicit in a nullable clinic_id, so every query that wanted "this environment's rows" had to rebuild clinic_id IS NULL ? doctor : clinic itself. The two calendar tables also depended on a MariaDB-only generated column, clinic_key = IFNULL(clinic_id, 0), purely to make a unique key work across NULLs. All three now carry the (entity_type, entity_id) pair that service_sections, patient_records and clinic_staff already use, via a shared TenantOwnedTrait. The pair is a deliberate denormalisation of clinic_id/doctor_id: the automatic tenant filter and the tenant-leading indexes both need a real column, and neither can be built on an IF() expression. - Unique keys keep doctor_id alongside the pair. A clinic has several doctors and each has their own schedule, so (entity_type, entity_id) alone would reject the second doctor. - clinic_key is gone from both calendar tables. - appointments gained tenant-leading indexes; EXPLAIN on the panel's list query now picks idx_appointments_tenant_slot. Deliberately unchanged, both with the reason already recorded in the code: active_slot_key stays keyed on doctor + slot, since adding the environment would let one doctor be booked in their own practice and a clinic at the same moment. holidays keeps its nullable clinic_id, where NULL means "every environment" rather than "personal practice" — a meaning the pair cannot carry. The migration adds the columns nullable, backfills, aborts if any row is left without an owner, and only then tightens to NOT NULL. It creates each replacement unique index before dropping the old one, so the tables are never left unprotected — MariaDB commits implicitly on DDL, so ordering is the only safety net. It runs its statements through the connection rather than addSql() because the guard has to sit between the backfill and the NOT NULL change. Columns are NOT NULL with no default on purpose: a construction site that forgets assignTenant() fails at flush instead of silently writing entity_id 0, which the phase 4 filter would then hide from everyone. Verified on the dev database: 0 rows without a tenant, 0 personal bookings mismatched against their doctor, 0 clinic bookings mismatched against their clinic. Tests: 819 passing (813 + 6 new in BookingTenantTest). PHPStan clean on every changed file. Co-Authored-By: Claude Opus 5 (1M context) --- migrations/Version20260728074119.php | 106 ++++++++++++++ src/Admin/Controller/AdminApiController.php | 1 + .../Controller/AppointmentController.php | 1 + .../AppointmentSettingsController.php | 2 + .../Controller/MyAppointmentsController.php | 2 + src/Appointment/Entity/Appointment.php | 14 +- src/Appointment/Entity/DateOverride.php | 11 +- src/Appointment/Entity/Holiday.php | 4 + src/Appointment/Entity/WeeklySchedule.php | 19 +-- src/Shared/Context/EntityContext.php | 9 ++ src/Shared/Tenant/TenantOwnedTrait.php | 46 ++++++ tests/ApiTestCase.php | 74 ++++++++++ .../AppointmentConfirmFlowTest.php | 3 +- .../AppointmentConfirmInsuranceSharesTest.php | 2 +- .../AppointmentExpiryServiceTest.php | 4 +- .../AppointmentInsuranceSelectionTest.php | 2 +- tests/Appointment/AppointmentUpdateTest.php | 4 +- .../AppointmentWorkflowFieldsTest.php | 2 +- .../BookingLocationValidityTest.php | 6 +- .../Appointment/BookingLocationsScanTest.php | 4 +- .../Appointment/BookingServicesPublicTest.php | 2 +- tests/Appointment/BookingTenantTest.php | 137 ++++++++++++++++++ tests/Appointment/BookingWindowUnitTest.php | 4 +- .../ClinicAppointmentAccessTest.php | 3 +- .../Appointment/DateOverrideOwnershipTest.php | 2 +- .../DoctorAppointmentFilterTest.php | 2 +- .../OnlineBookingManagementTest.php | 4 +- tests/Appointment/ScheduleOwnershipTest.php | 2 +- tests/Appointment/ServiceBasedSlotsTest.php | 6 +- .../ServiceModeSectionDurationTest.php | 2 +- tests/Appointment/SlotUniquenessTest.php | 2 +- tests/Dashboard/DashboardChartPeriodTest.php | 2 +- .../DashboardTodayAppointmentsTest.php | 2 +- tests/Database/UniqueConstraintsTest.php | 4 +- .../DoctorBookingStateAggregationTest.php | 4 +- .../DoctorListBookableSortFilterTest.php | 6 +- .../AutoCreateSessionOnConfirmTest.php | 5 +- tests/Patient/ClinicRecordAccessTest.php | 6 +- tests/Patient/PatientAppointmentsTest.php | 2 +- ...AppointmentPaidConfirmFilesSessionTest.php | 2 +- .../SecretaryAppointmentScopeTest.php | 6 +- tests/Secretary/SecretaryOnlineShareTest.php | 3 +- 42 files changed, 455 insertions(+), 69 deletions(-) create mode 100644 migrations/Version20260728074119.php create mode 100644 src/Shared/Tenant/TenantOwnedTrait.php create mode 100644 tests/Appointment/BookingTenantTest.php diff --git a/migrations/Version20260728074119.php b/migrations/Version20260728074119.php new file mode 100644 index 00000000..35c6ed22 --- /dev/null +++ b/migrations/Version20260728074119.php @@ -0,0 +1,106 @@ +connection instead of addSql() because the + * backfill has to happen between adding the nullable columns and tightening + * them to NOT NULL — addSql() defers everything to the end of up(), which would + * run the guard before the data exists. + * + * MariaDB commits implicitly on DDL, so this migration cannot roll back as a + * unit. The order below is what keeps it safe: the replacement unique index is + * created before the old one is dropped, so the tables are never left without + * uniqueness protection. + */ +final class Version20260728074119 extends AbstractMigration +{ + /** clinic wins when set; NULL means the doctor's own practice. */ + private const BACKFILL = "SET entity_type = IF(clinic_id IS NULL, 'doctor', 'clinic'), + entity_id = IFNULL(clinic_id, doctor_id)"; + + public function getDescription(): string + { + return 'Mark appointments, weekly_schedules and date_overrides with their owning tenant'; + } + + public function up(Schema $schema): void + { + foreach (['appointments', 'weekly_schedules', 'date_overrides'] as $table) { + $this->connection->executeStatement( + "ALTER TABLE {$table} ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL" + ); + $this->connection->executeStatement("UPDATE {$table} " . self::BACKFILL); + + // Defensive: a clinic_id pointing at a deleted clinic falls back to + // the doctor's own practice rather than aborting the migration. + $this->connection->executeStatement( + "UPDATE {$table} t LEFT JOIN clinics c ON c.id = t.clinic_id + SET t.entity_type = 'doctor', t.entity_id = t.doctor_id + WHERE t.clinic_id IS NOT NULL AND c.id IS NULL" + ); + + $remaining = (int) $this->connection->fetchOne( + "SELECT COUNT(*) FROM {$table} WHERE entity_type IS NULL OR entity_id IS NULL" + ); + $this->abortIf($remaining > 0, "Backfill left {$remaining} rows in {$table} without a tenant."); + + $this->connection->executeStatement( + "ALTER TABLE {$table} MODIFY entity_type VARCHAR(10) NOT NULL, MODIFY entity_id INT NOT NULL" + ); + } + + $this->connection->executeStatement( + 'CREATE INDEX idx_appointments_tenant_slot ON appointments (entity_type, entity_id, slot_start)' + ); + $this->connection->executeStatement( + 'CREATE INDEX idx_appointments_tenant_status ON appointments (entity_type, entity_id, status)' + ); + + // New unique key first, old one and its generated column afterwards. + $this->connection->executeStatement( + 'CREATE UNIQUE INDEX uniq_weekly_schedule_doctor_tenant ON weekly_schedules (doctor_id, entity_type, entity_id)' + ); + $this->connection->executeStatement('DROP INDEX idx_weekly_schedules_doctor_clinic ON weekly_schedules'); + $this->connection->executeStatement('ALTER TABLE weekly_schedules DROP clinic_key'); + + $this->connection->executeStatement( + 'CREATE UNIQUE INDEX uniq_date_override_doctor_tenant_date ON date_overrides (doctor_id, entity_type, entity_id, date)' + ); + $this->connection->executeStatement('DROP INDEX uniq_date_override_doctor_clinic_date ON date_overrides'); + $this->connection->executeStatement('ALTER TABLE date_overrides DROP clinic_key'); + } + + public function down(Schema $schema): void + { + $this->addSql("ALTER TABLE weekly_schedules ADD clinic_key INT AS (IFNULL(clinic_id, 0)) STORED"); + $this->addSql('CREATE UNIQUE INDEX idx_weekly_schedules_doctor_clinic ON weekly_schedules (doctor_id, clinic_key)'); + $this->addSql('DROP INDEX uniq_weekly_schedule_doctor_tenant ON weekly_schedules'); + + $this->addSql("ALTER TABLE date_overrides ADD clinic_key INT AS (IFNULL(clinic_id, 0)) STORED"); + $this->addSql('CREATE UNIQUE INDEX uniq_date_override_doctor_clinic_date ON date_overrides (doctor_id, clinic_key, date)'); + $this->addSql('DROP INDEX uniq_date_override_doctor_tenant_date ON date_overrides'); + + $this->addSql('DROP INDEX idx_appointments_tenant_slot ON appointments'); + $this->addSql('DROP INDEX idx_appointments_tenant_status ON appointments'); + + foreach (['appointments', 'weekly_schedules', 'date_overrides'] as $table) { + $this->addSql("ALTER TABLE {$table} DROP entity_type, DROP entity_id"); + } + } + + /** DDL on MariaDB commits implicitly; wrapping up() in a transaction would be a lie. */ + public function isTransactional(): bool + { + return false; + } +} diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index 858230b2..4afef556 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -1018,6 +1018,7 @@ class AdminApiController extends BaseController } $bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null); $appointment->setClinic($bookingClinic); + $appointment->assignTenant(\App\Shared\Context\EntityContext::forBooking($doctor, $bookingClinic)); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true); if ($locationId !== null) $appointment->setAddressId($locationId); diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 584a13fe..f5018e15 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -519,6 +519,7 @@ class AppointmentController extends BaseController // آدرس نوبت از روی session متناظر در برنامه‌ی هفتگی تعیین می‌شود (location_id). $appointment->setClinic($bookingClinic); + $appointment->assignTenant(\App\Shared\Context\EntityContext::forBooking($doctor, $bookingClinic)); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic); if ($locationId !== null) { $appointment->setAddressId($locationId); diff --git a/src/Appointment/Controller/AppointmentSettingsController.php b/src/Appointment/Controller/AppointmentSettingsController.php index 8645fd54..564b3a2a 100644 --- a/src/Appointment/Controller/AppointmentSettingsController.php +++ b/src/Appointment/Controller/AppointmentSettingsController.php @@ -152,6 +152,7 @@ class AppointmentSettingsController extends BaseController $schedule->setSetting($data['schedule'] ?? []); } else { $schedule = new WeeklySchedule($doctor, $data['schedule'] ?? [], $clinic); + $schedule->assignTenant(EntityContext::forBooking($doctor, $clinic)); } if (isset($data['meta']) && is_array($data['meta'])) { @@ -310,6 +311,7 @@ class AppointmentSettingsController extends BaseController } $override = new DateOverride($doctor, $timestamp, (bool) ($data['active'] ?? false), $clinic); + $override->assignTenant(EntityContext::forBooking($doctor, $clinic)); if (isset($data['reason'])) $override->setReason($data['reason']); if (isset($data['custom_slots'])) $override->setSetting($data['custom_slots']); diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 8c302e4a..65aff488 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -3,6 +3,7 @@ namespace App\Appointment\Controller; use App\Appointment\Entity\Appointment; +use App\Shared\Context\EntityContext; use App\Shared\Constant\ErrorCodes; use App\Appointment\Repository\AppointmentRepository; use App\Appointment\Repository\SlotTakenException; @@ -182,6 +183,7 @@ class MyAppointmentsController extends BaseController // یعنی مطب شخصی، نه «هر برنامه‌ای که پیدا شد». $bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null); $appointment->setClinic($bookingClinic); + $appointment->assignTenant(EntityContext::forBooking($doctor, $bookingClinic)); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true); if ($locationId !== null) $appointment->setAddressId($locationId); diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index d4573de1..1552654b 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -5,6 +5,7 @@ namespace App\Appointment\Entity; use App\Auth\Entity\User; use App\Doctor\Entity\Doctor; use App\Insurance\Enum\ServiceCategory; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -13,11 +14,22 @@ use Symfony\Component\Uid\Uuid; #[ORM\Entity(repositoryClass: AppointmentRepository::class)] #[ORM\Table(name: 'appointments')] +// tenant پیشرو — لیست‌های پنل همیشه محیط‌محورند +#[ORM\Index(columns: ['entity_type', 'entity_id', 'slot_start'], name: 'idx_appointments_tenant_slot')] +#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_appointments_tenant_status')] +// بدون tenant — عمدی: یکتاییِ اسلات و تقویم سطح پزشک‌اند، نه محیط #[ORM\Index(columns: ['doctor_id', 'slot_start'], name: 'idx_appointments_doctor_slot')] #[ORM\Index(columns: ['user_id', 'status'], name: 'idx_appointments_user_status')] #[ORM\Index(columns: ['status', 'expires_at'], name: 'idx_appointments_status_expires')] class Appointment { + /** + * محیطِ مالکِ نوبت. denormalization عمدی روی clinic/doctor موجود: فیلترِ خودکارِ + * tenant و ایندکسِ tenant-پیشرو هر دو به ستون واقعی نیاز دارند و با شرطِ + * IF(clinic_id IS NULL, …) ساخته نمی‌شوند. + */ + use TenantOwnedTrait; + // Status machine: pending → confirmed → completed // ↘ cancelled_by_doctor / cancelled_by_user // pending → expired (cron) @@ -214,7 +226,7 @@ class Appointment * while the appointment occupies the slot; null once it is cancelled. * * کلید عمداً clinic ندارد و فقط doctor+slotStart است: برنامهٔ هفتگی هر محیط - * جداست (WeeklySchedule با UNIQUE(doctor_id, clinic_key)) و می‌تواند با محیط + * جداست (WeeklySchedule با UNIQUE(doctor_id, entity_type, entity_id)) و می‌تواند با محیط * دیگر هم‌پوشانی داشته باشد، ولی پزشک یک نفر است. افزودن clinic به کلید یعنی * اجازهٔ رزرو هم‌زمان همان پزشک در مطب و کلینیک — نه رفع باگ. */ diff --git a/src/Appointment/Entity/DateOverride.php b/src/Appointment/Entity/DateOverride.php index 39df5b6e..59f7d707 100644 --- a/src/Appointment/Entity/DateOverride.php +++ b/src/Appointment/Entity/DateOverride.php @@ -4,6 +4,8 @@ namespace App\Appointment\Entity; use App\Clinic\Entity\Clinic; use App\Doctor\Entity\Doctor; +use App\Shared\Context\EntityContext; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use App\Appointment\Repository\DateOverrideRepository; use Symfony\Component\Uid\Uuid; @@ -14,9 +16,12 @@ use Symfony\Component\Uid\Uuid; */ #[ORM\Entity(repositoryClass: DateOverrideRepository::class)] #[ORM\Table(name: 'date_overrides')] -#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_clinic_date', columns: ['doctor_id', 'clinic_key', 'date'])] +// doctor_id در کلید می‌ماند: چند پزشکِ یک کلینیک هر کدام استثنای روزِ خودشان را دارند. +#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_tenant_date', columns: ['doctor_id', 'entity_type', 'entity_id', 'date'])] class DateOverride { + use TenantOwnedTrait; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] @@ -34,10 +39,6 @@ class DateOverride #[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] private ?Clinic $clinic = null; - /** ستون تولیدشده: IFNULL(clinic_id, 0) — تا یکتایی با clinic_id تهی هم برقرار بماند. */ - #[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false, generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')] - private int $clinicKey = 0; - #[ORM\Column(type: 'integer')] private int $date; diff --git a/src/Appointment/Entity/Holiday.php b/src/Appointment/Entity/Holiday.php index 080272ea..44a00189 100644 --- a/src/Appointment/Entity/Holiday.php +++ b/src/Appointment/Entity/Holiday.php @@ -12,6 +12,10 @@ use Symfony\Component\Uid\Uuid; * تعطیلی پزشک. برخلاف WeeklySchedule و DateOverride، تعطیلی پیش‌فرضاً سراسری است: * «پزشک آن روز نیست» یک واقعیت فیزیکی است و هم‌زمان روی مطب شخصی و همهٔ کلینیک‌ها * اثر می‌گذارد (clinic = null). مقدار غیر-NULL یعنی پزشک فقط در همان کلینیک نیست. + * + * عمداً جفت (entity_type, entity_id) ندارد: در این جدول clinic = NULL یعنی «همهٔ + * محیط‌ها»، نه «مطب شخصی». جفت tenant نمی‌تواند «همه» را بیان کند و تبدیلش، تعطیلی + * سراسری را به تعطیلی مطب شخصی تنزل می‌دهد. باید در whitelist فیلتر tenant بماند. */ #[ORM\Entity(repositoryClass: HolidayRepository::class)] #[ORM\Table(name: 'holidays')] diff --git a/src/Appointment/Entity/WeeklySchedule.php b/src/Appointment/Entity/WeeklySchedule.php index cbc6d349..e4b922ad 100644 --- a/src/Appointment/Entity/WeeklySchedule.php +++ b/src/Appointment/Entity/WeeklySchedule.php @@ -4,6 +4,8 @@ namespace App\Appointment\Entity; use App\Clinic\Entity\Clinic; use App\Doctor\Entity\Doctor; +use App\Shared\Context\EntityContext; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use App\Appointment\Repository\WeeklyScheduleRepository; use Symfony\Component\Uid\Uuid; @@ -17,9 +19,13 @@ use Symfony\Component\Uid\Uuid; */ #[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)] #[ORM\Table(name: 'weekly_schedules')] -#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor_clinic', columns: ['doctor_id', 'clinic_key'])] +// doctor_id در کلید می‌ماند: در یک کلینیک چند پزشک هستند و هر کدام برنامهٔ خودش را +// دارد، پس (entity_type, entity_id) به‌تنهایی برای پزشک دوم نقض یکتایی می‌سازد. +#[ORM\UniqueConstraint(name: 'uniq_weekly_schedule_doctor_tenant', columns: ['doctor_id', 'entity_type', 'entity_id'])] class WeeklySchedule { + use TenantOwnedTrait; + public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday']; public const META_KEY = 'meta'; @@ -55,16 +61,6 @@ class WeeklySchedule #[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] private ?Clinic $clinic = null; - /** - * ستون تولیدشدهٔ پایگاه‌داده: IFNULL(clinic_id, 0). - * - * MySQL/MariaDB مقادیر NULL را در unique index متمایز می‌شمارند، پس - * UNIQUE(doctor_id, clinic_id) جلوی دو برنامهٔ شخصی برای یک پزشک را نمی‌گرفت. - * این ستون NULL را به 0 نگاشت می‌کند تا یکتایی در سطح دیتابیس تضمین شود. - */ - #[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false, generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')] - private int $clinicKey = 0; - #[ORM\Column(type: 'json')] private array $setting = []; @@ -94,6 +90,7 @@ class WeeklySchedule { $this->clinic = $clinic; $this->updatedAt = time(); + $this->assignTenant(EntityContext::forBooking($this->doctor, $clinic)); return $this; } public function getSetting(): array { return $this->setting; } diff --git a/src/Shared/Context/EntityContext.php b/src/Shared/Context/EntityContext.php index 5febf6a6..d2e571c0 100644 --- a/src/Shared/Context/EntityContext.php +++ b/src/Shared/Context/EntityContext.php @@ -35,6 +35,15 @@ final class EntityContext return new self(self::TYPE_CLINIC, $clinic->getId(), $clinic); } + /** + * محیط یک رزرو: کلینیکِ داده‌شده، وگرنه مطب شخصی همان پزشک — همان قراردادی که + * {@see \App\Appointment\Service\BookingContextResolver} با ?Clinic بیان می‌کند. + */ + public static function forBooking(Doctor $doctor, ?Clinic $clinic): self + { + return $clinic !== null ? self::forClinic($clinic) : self::forDoctor($doctor); + } + public static function unknown(): self { return new self(self::TYPE_UNKNOWN, null); diff --git a/src/Shared/Tenant/TenantOwnedTrait.php b/src/Shared/Tenant/TenantOwnedTrait.php new file mode 100644 index 00000000..f28632c0 --- /dev/null +++ b/src/Shared/Tenant/TenantOwnedTrait.php @@ -0,0 +1,46 @@ +entityType; } + + public function getEntityId(): int { return $this->entityId; } + + /** @throws \InvalidArgumentException اگر محیط حل نشده باشد */ + public function assignTenant(EntityContext $context): void + { + if (!$context->isResolved()) { + throw new \InvalidArgumentException(sprintf( + 'Cannot assign an unresolved tenant context to %s.', + static::class, + )); + } + + [$this->entityType, $this->entityId] = $context->toEntityPair(); + } +} diff --git a/tests/ApiTestCase.php b/tests/ApiTestCase.php index a6ba86d5..d26aa30b 100644 --- a/tests/ApiTestCase.php +++ b/tests/ApiTestCase.php @@ -2,7 +2,13 @@ namespace App\Tests; +use App\Appointment\Entity\Appointment; +use App\Appointment\Entity\DateOverride; +use App\Appointment\Entity\WeeklySchedule; use App\Auth\Entity\User; +use App\Clinic\Entity\Clinic; +use App\Doctor\Entity\Doctor; +use App\Shared\Context\EntityContext; use App\Subscription\Entity\SubscriptionPlan; use Doctrine\ORM\EntityManagerInterface; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; @@ -70,6 +76,74 @@ abstract class ApiTestCase extends WebTestCase return $user; } + /** + * A booking with its owning tenant already assigned — the test-side mirror of + * what the booking controllers do after resolving the environment. Constructing + * an Appointment without it fails at flush, since entity_type/entity_id are NOT + * NULL and have no default. + */ + protected function newAppointment( + Doctor $doctor, + User $patient, + int $slotStart, + int $slotEnd, + ?Clinic $clinic = null, + ): Appointment { + $appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd); + $appointment->setClinic($clinic); + $appointment->assignTenant(EntityContext::forBooking($doctor, $clinic)); + + return $appointment; + } + + /** + * A weekly schedule with its owning tenant assigned. The doctor is flushed + * first when it has no id yet: the tenant pair stores scalars, so the owner + * must already exist in the database. + */ + protected function newWeeklySchedule(Doctor $doctor, array $setting, ?Clinic $clinic = null): WeeklySchedule + { + $this->flushIfNew($doctor, $clinic); + + $schedule = new WeeklySchedule($doctor, $setting, $clinic); + $schedule->assignTenant(EntityContext::forBooking($doctor, $clinic)); + + return $schedule; + } + + protected function newDateOverride(Doctor $doctor, int $date, bool $active = false, ?Clinic $clinic = null): DateOverride + { + $this->flushIfNew($doctor, $clinic); + + $override = new DateOverride($doctor, $date, $active, $clinic); + $override->assignTenant(EntityContext::forBooking($doctor, $clinic)); + + return $override; + } + + /** + * The tenant pair stores scalars, so the owner needs a database id before it + * can be assigned. persist() on an already-managed entity is a no-op, which + * makes this safe for owners the test never persisted itself. + */ + private function flushIfNew(Doctor $doctor, ?Clinic $clinic): void + { + $pending = array_filter( + [$doctor, $clinic], + static fn(?object $owner) => $owner !== null && $owner->getId() === null, + ); + + if ($pending === []) { + return; + } + + foreach ($pending as $owner) { + $this->em->persist($owner); + } + + $this->em->flush(); + } + protected function jwtFor(User $user): string { return static::getContainer() diff --git a/tests/Appointment/AppointmentConfirmFlowTest.php b/tests/Appointment/AppointmentConfirmFlowTest.php index b88f982e..db051981 100644 --- a/tests/Appointment/AppointmentConfirmFlowTest.php +++ b/tests/Appointment/AppointmentConfirmFlowTest.php @@ -54,8 +54,7 @@ class AppointmentConfirmFlowTest extends ApiTestCase // اسلات یکتا به‌ازای هر نوبت: db_test بین اجراها پاک نمی‌شود. $start = strtotime('+30 days') + random_int(0, 500_000) * 7; - $appointment = new Appointment($doctor, $patient, $start, $start + 900); - $appointment->setClinic($clinic); + $appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic); $appointment->setVisitPriceRials($visitPriceRials); $this->em->persist($appointment); $this->em->flush(); diff --git a/tests/Appointment/AppointmentConfirmInsuranceSharesTest.php b/tests/Appointment/AppointmentConfirmInsuranceSharesTest.php index 40904119..5389c422 100644 --- a/tests/Appointment/AppointmentConfirmInsuranceSharesTest.php +++ b/tests/Appointment/AppointmentConfirmInsuranceSharesTest.php @@ -35,7 +35,7 @@ class AppointmentConfirmInsuranceSharesTest extends ApiTestCase private function makeAppointment(Doctor $doctor): Appointment { $start = strtotime('+30 days') + random_int(0, 500_000) * 7; - $appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900); + $appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900); $appointment->setVisitPriceRials(self::VISIT_RIALS); $this->em->persist($appointment); $this->em->flush(); diff --git a/tests/Appointment/AppointmentExpiryServiceTest.php b/tests/Appointment/AppointmentExpiryServiceTest.php index e10cd921..972293cd 100644 --- a/tests/Appointment/AppointmentExpiryServiceTest.php +++ b/tests/Appointment/AppointmentExpiryServiceTest.php @@ -27,7 +27,7 @@ class AppointmentExpiryServiceTest extends ApiTestCase $patient = $this->createUser(['ROLE_USER']); // distinct past slots — one live booking per (doctor, slot) $slotStart = $past - $i * 1000; - $appt = new Appointment($doctor, $patient, $slotStart, $slotStart + 900); + $appt = $this->newAppointment($doctor, $patient, $slotStart, $slotStart + 900); // مثل مسیر واقعیِ رزرو آنلاین: نگه‌داشتِ موقت تا پرداخت درگاه. $appt->markPendingWithTtl(-1); $this->em->persist($appt); @@ -67,7 +67,7 @@ class AppointmentExpiryServiceTest extends ApiTestCase $this->em->persist($doctor); $slotStart = time() - 7200; - $appt = new Appointment($doctor, $this->createUser(['ROLE_USER']), $slotStart, $slotStart + 900); + $appt = $this->newAppointment($doctor, $this->createUser(['ROLE_USER']), $slotStart, $slotStart + 900); $this->em->persist($appt); $this->em->flush(); diff --git a/tests/Appointment/AppointmentInsuranceSelectionTest.php b/tests/Appointment/AppointmentInsuranceSelectionTest.php index 24ef240a..4c48940d 100644 --- a/tests/Appointment/AppointmentInsuranceSelectionTest.php +++ b/tests/Appointment/AppointmentInsuranceSelectionTest.php @@ -31,7 +31,7 @@ class AppointmentInsuranceSelectionTest extends ApiTestCase { // اسلات یکتا به‌ازای هر نوبت: db_test بین اجراها پاک نمی‌شود. $start = strtotime('+30 days') + random_int(0, 500_000) * 7; - $appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900); + $appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900); $appointment->setVisitPriceRials(5_952_000); $this->em->persist($appointment); $this->em->flush(); diff --git a/tests/Appointment/AppointmentUpdateTest.php b/tests/Appointment/AppointmentUpdateTest.php index 6b5924ec..cb343422 100644 --- a/tests/Appointment/AppointmentUpdateTest.php +++ b/tests/Appointment/AppointmentUpdateTest.php @@ -23,7 +23,7 @@ class AppointmentUpdateTest extends ApiTestCase $this->em->persist($doctor); $this->em->flush(); - $appointment = new Appointment($doctor, $this->createUser(), time() + 86_400, time() + 86_400 + 1_800); + $appointment = $this->newAppointment($doctor, $this->createUser(), time() + 86_400, time() + 86_400 + 1_800); $this->em->persist($appointment); $this->em->flush(); @@ -77,7 +77,7 @@ class AppointmentUpdateTest extends ApiTestCase [$owner, $doctor, $appointment] = $this->booking(); $otherStart = time() + 3 * 86_400; - $this->em->persist(new Appointment($doctor, $this->createUser(), $otherStart, $otherStart + 1_800)); + $this->em->persist($this->newAppointment($doctor, $this->createUser(), $otherStart, $otherStart + 1_800)); $this->em->flush(); $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [ diff --git a/tests/Appointment/AppointmentWorkflowFieldsTest.php b/tests/Appointment/AppointmentWorkflowFieldsTest.php index 7e780beb..70ecaa5c 100644 --- a/tests/Appointment/AppointmentWorkflowFieldsTest.php +++ b/tests/Appointment/AppointmentWorkflowFieldsTest.php @@ -24,7 +24,7 @@ class AppointmentWorkflowFieldsTest extends ApiTestCase private function newBooking(Doctor $doctor, int $start): Appointment { - $a = new Appointment($doctor, $this->createUser(), $start, $start + 1_800); + $a = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800); $this->em->persist($a); $this->em->flush(); diff --git a/tests/Appointment/BookingLocationValidityTest.php b/tests/Appointment/BookingLocationValidityTest.php index b3f5d802..a5e00e8a 100644 --- a/tests/Appointment/BookingLocationValidityTest.php +++ b/tests/Appointment/BookingLocationValidityTest.php @@ -50,7 +50,7 @@ class BookingLocationValidityTest extends ApiTestCase 'duration_per_patient' => 20, ], fn($v) => $v !== null)]]; - $schedule = new WeeklySchedule( + $schedule = $this->newWeeklySchedule( $doctor, array_fill_keys(array_map('strval', range(0, 6)), $day), $clinic @@ -128,7 +128,7 @@ class BookingLocationValidityTest extends ApiTestCase ]]]; $setting = array_fill_keys(array_map('strval', range(0, 6)), ['sessions' => []]); $setting['0'] = $active; - $this->em->persist(new WeeklySchedule($doctor, $setting)); + $this->em->persist($this->newWeeklySchedule($doctor, $setting)); $this->em->flush(); $saturday = $this->nextWeekday(6); // date('w'): 6 = Saturday @@ -165,7 +165,7 @@ class BookingLocationValidityTest extends ApiTestCase $this->em->flush(); $setting = array_fill_keys(array_map('strval', range(0, 6)), ['sessions' => []]); - $this->em->persist(new WeeklySchedule($doctor, $setting)); + $this->em->persist($this->newWeeklySchedule($doctor, $setting)); $this->em->flush(); $body = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$today}", $doctor->getUser()); diff --git a/tests/Appointment/BookingLocationsScanTest.php b/tests/Appointment/BookingLocationsScanTest.php index 28ddda77..92c48b1c 100644 --- a/tests/Appointment/BookingLocationsScanTest.php +++ b/tests/Appointment/BookingLocationsScanTest.php @@ -43,7 +43,7 @@ class BookingLocationsScanTest extends ApiTestCase $this->em->persist($personalAddress); $this->em->flush(); - $this->em->persist(new WeeklySchedule( + $this->em->persist($this->newWeeklySchedule( $doctor, $this->weekOfSessions($personalAddress->getId(), '09:00', '13:00') )); @@ -59,7 +59,7 @@ class BookingLocationsScanTest extends ApiTestCase $this->em->persist($address); $this->em->flush(); - $this->em->persist(new WeeklySchedule( + $this->em->persist($this->newWeeklySchedule( $doctor, $this->weekOfSessions($address->getId(), '16:00', '20:00'), $clinic diff --git a/tests/Appointment/BookingServicesPublicTest.php b/tests/Appointment/BookingServicesPublicTest.php index dcc06438..d1613b4c 100644 --- a/tests/Appointment/BookingServicesPublicTest.php +++ b/tests/Appointment/BookingServicesPublicTest.php @@ -29,7 +29,7 @@ class BookingServicesPublicTest extends ApiTestCase $this->em->persist($bookable); $this->em->persist($hidden); - $schedule = new WeeklySchedule($doctor, []); + $schedule = $this->newWeeklySchedule($doctor, []); $schedule->setMeta(['booking_mode' => 'service', 'buffer_minutes' => 5]); $this->em->persist($schedule); $this->em->flush(); diff --git a/tests/Appointment/BookingTenantTest.php b/tests/Appointment/BookingTenantTest.php new file mode 100644 index 00000000..f3928fc2 --- /dev/null +++ b/tests/Appointment/BookingTenantTest.php @@ -0,0 +1,137 @@ +createUser(['ROLE_DOCTOR']), 'دکتر تست محیط'); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + private function makeClinic(): Clinic + { + $clinic = new Clinic($this->createUser(['ROLE_CLINIC'])); + $clinic->setName('کلینیک تست محیط'); + $this->em->persist($clinic); + $this->em->flush(); + + return $clinic; + } + + private function uniqueSlot(): int + { + return strtotime('+90 days') + random_int(0, 500_000) * 7; + } + + public function testPersonalBookingIsOwnedByTheDoctor(): void + { + $doctor = $this->makeDoctor(); + $start = $this->uniqueSlot(); + + $appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900); + $this->em->persist($appointment); + $this->em->flush(); + + self::assertSame('doctor', $appointment->getEntityType()); + self::assertSame($doctor->getId(), $appointment->getEntityId()); + } + + public function testClinicBookingIsOwnedByTheClinic(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinic(); + $clinic->getDoctors()->add($doctor); + $this->em->flush(); + + $start = $this->uniqueSlot(); + $appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900, $clinic); + $this->em->persist($appointment); + $this->em->flush(); + + self::assertSame('clinic', $appointment->getEntityType()); + self::assertSame($clinic->getId(), $appointment->getEntityId()); + } + + /** یک پزشک در دو محیط = دو مالک متفاوت برای دو نوبت. */ + public function testSameDoctorGetsDifferentOwnersInDifferentEnvironments(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinic(); + $clinic->getDoctors()->add($doctor); + $this->em->flush(); + + $personal = $this->newAppointment($doctor, $this->createUser(), $this->uniqueSlot(), $this->uniqueSlot() + 900); + $inClinic = $this->newAppointment($doctor, $this->createUser(), $this->uniqueSlot(), $this->uniqueSlot() + 900, $clinic); + $this->em->persist($personal); + $this->em->persist($inClinic); + $this->em->flush(); + + self::assertSame(['doctor', $doctor->getId()], [$personal->getEntityType(), $personal->getEntityId()]); + self::assertSame(['clinic', $clinic->getId()], [$inClinic->getEntityType(), $inClinic->getEntityId()]); + } + + /** ❌ محیط حل‌نشده هرگز ذخیره نمی‌شود — نه با شناسهٔ صفر، نه بی‌صدا. */ + public function testAssigningAnUnresolvedContextIsRejected(): void + { + $doctor = $this->makeDoctor(); + $start = $this->uniqueSlot(); + $appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900); + + $this->expectException(\InvalidArgumentException::class); + $appointment->assignTenant(EntityContext::unknown()); + } + + /** + * ⚠️ حالت مرزی: دو پزشکِ متفاوت در یک کلینیک، هر کدام برنامهٔ هفتگی خودشان — + * کلید یکتا باید doctor_id را هم داشته باشد وگرنه پزشک دوم رد می‌شود. + */ + public function testTwoDoctorsInOneClinicEachKeepTheirOwnSchedule(): void + { + $clinic = $this->makeClinic(); + $doctorA = $this->makeDoctor(); + $doctorB = $this->makeDoctor(); + $clinic->getDoctors()->add($doctorA); + $clinic->getDoctors()->add($doctorB); + $this->em->flush(); + + $this->em->persist($this->newWeeklySchedule($doctorA, [], $clinic)); + $this->em->persist($this->newWeeklySchedule($doctorB, [], $clinic)); + $this->em->flush(); + + $rows = $this->em->getRepository(WeeklySchedule::class) + ->findBy(['entityType' => 'clinic', 'entityId' => $clinic->getId()]); + + self::assertCount(2, $rows); + } + + /** همان پزشک، همان محیط، دو برنامه — هنوز غیرممکن است. */ + public function testOneSchedulePerEnvironmentPerDoctorIsStillEnforced(): void + { + $doctor = $this->makeDoctor(); + + $this->em->persist($this->newWeeklySchedule($doctor, [])); + $this->em->flush(); + + $this->em->persist($this->newWeeklySchedule($doctor, [])); + $this->expectException(UniqueConstraintViolationException::class); + $this->em->flush(); + } +} diff --git a/tests/Appointment/BookingWindowUnitTest.php b/tests/Appointment/BookingWindowUnitTest.php index 63607a87..f044b303 100644 --- a/tests/Appointment/BookingWindowUnitTest.php +++ b/tests/Appointment/BookingWindowUnitTest.php @@ -30,7 +30,7 @@ class BookingWindowUnitTest extends ApiTestCase ]]]; } - $schedule = new WeeklySchedule($doctor, $days); + $schedule = $this->newWeeklySchedule($doctor, $days); if ($meta !== []) { $schedule->setMeta($meta); } @@ -94,7 +94,7 @@ class BookingWindowUnitTest extends ApiTestCase { $owner = $this->createUser(['ROLE_DOCTOR']); $doctor = new Doctor($owner, 'دکتر واحد نامعتبر'); - $schedule = new WeeklySchedule($doctor, []); + $schedule = $this->newWeeklySchedule($doctor, []); $schedule->setMeta(['booking_window_unit' => 'day', 'booking_window_value' => 10]); $schedule->setMeta(['booking_window_unit' => 'year']); diff --git a/tests/Appointment/ClinicAppointmentAccessTest.php b/tests/Appointment/ClinicAppointmentAccessTest.php index 073ea178..52d8790b 100644 --- a/tests/Appointment/ClinicAppointmentAccessTest.php +++ b/tests/Appointment/ClinicAppointmentAccessTest.php @@ -49,8 +49,7 @@ class ClinicAppointmentAccessTest extends ApiTestCase $patient ??= $this->createUser(); $start = strtotime('+3 days 10:00'); - $appointment = new Appointment($doctor, $patient, $start, $start + 900); - $appointment->setClinic($clinic); + $appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic); $this->em->persist($appointment); $this->em->flush(); diff --git a/tests/Appointment/DateOverrideOwnershipTest.php b/tests/Appointment/DateOverrideOwnershipTest.php index 7d502ee7..b31c78a3 100644 --- a/tests/Appointment/DateOverrideOwnershipTest.php +++ b/tests/Appointment/DateOverrideOwnershipTest.php @@ -16,7 +16,7 @@ class DateOverrideOwnershipTest extends ApiTestCase $owner = $this->createUser(['ROLE_DOCTOR']); $doctor = new Doctor($owner, 'دکتر تست'); $this->em->persist($doctor); - $override = new DateOverride($doctor, time(), true); + $override = $this->newDateOverride($doctor, time(), true); $this->em->persist($override); $this->em->flush(); diff --git a/tests/Appointment/DoctorAppointmentFilterTest.php b/tests/Appointment/DoctorAppointmentFilterTest.php index dd598482..6861bdac 100644 --- a/tests/Appointment/DoctorAppointmentFilterTest.php +++ b/tests/Appointment/DoctorAppointmentFilterTest.php @@ -29,7 +29,7 @@ class DoctorAppointmentFilterTest extends ApiTestCase private function booking(Doctor $doctor, int $start, ?string $status = null, ?string $name = null): Appointment { - $a = new Appointment($doctor, $this->createUser(), $start, $start + 1_800); + $a = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800); if ($name !== null) { $a->setPatientName($name); } diff --git a/tests/Appointment/OnlineBookingManagementTest.php b/tests/Appointment/OnlineBookingManagementTest.php index a629eb31..f757ba51 100644 --- a/tests/Appointment/OnlineBookingManagementTest.php +++ b/tests/Appointment/OnlineBookingManagementTest.php @@ -26,7 +26,7 @@ class OnlineBookingManagementTest extends ApiTestCase $date = date('Y-m-d', strtotime('tomorrow')); $dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7); - $schedule = new WeeklySchedule($doctor, [ + $schedule = $this->newWeeklySchedule($doctor, [ $dayKey => ['sessions' => [[ 'active' => true, 'start_time' => '10:00', @@ -143,7 +143,7 @@ class OnlineBookingManagementTest extends ApiTestCase $dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7); // برنامهٔ نوبت‌دهی در محیط کلینیک با نوبت‌دهی آنلاینِ خاموش. - $schedule = new WeeklySchedule($doctor, [ + $schedule = $this->newWeeklySchedule($doctor, [ $dayKey => ['sessions' => [[ 'active' => true, 'start_time' => '10:00', 'end_time' => '12:00', 'duration_per_patient' => 30, 'location_id' => 1, diff --git a/tests/Appointment/ScheduleOwnershipTest.php b/tests/Appointment/ScheduleOwnershipTest.php index ff75d4d3..253e8a93 100644 --- a/tests/Appointment/ScheduleOwnershipTest.php +++ b/tests/Appointment/ScheduleOwnershipTest.php @@ -18,7 +18,7 @@ class ScheduleOwnershipTest extends ApiTestCase $owner = $this->createUser(['ROLE_DOCTOR']); $doctor = new Doctor($owner, 'دکتر تست'); $this->em->persist($doctor); - $schedule = new WeeklySchedule($doctor, ['sat' => []]); + $schedule = $this->newWeeklySchedule($doctor, ['sat' => []]); $this->em->persist($schedule); $this->em->flush(); diff --git a/tests/Appointment/ServiceBasedSlotsTest.php b/tests/Appointment/ServiceBasedSlotsTest.php index 194f5ff8..331857e7 100644 --- a/tests/Appointment/ServiceBasedSlotsTest.php +++ b/tests/Appointment/ServiceBasedSlotsTest.php @@ -24,7 +24,7 @@ class ServiceBasedSlotsTest extends ApiTestCase $date = date('Y-m-d', strtotime('tomorrow')); $dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7); - $schedule = new WeeklySchedule($doctor, [ + $schedule = $this->newWeeklySchedule($doctor, [ $dayKey => ['sessions' => [[ 'active' => true, 'start_time' => '15:00', @@ -61,7 +61,7 @@ class ServiceBasedSlotsTest extends ApiTestCase $patient = $this->createUser(['ROLE_USER']); $start = strtotime($date . ' 15:00'); - $appt = new Appointment($doctor, $patient, $start, $start + 30 * 60); + $appt = $this->newAppointment($doctor, $patient, $start, $start + 30 * 60); $appt->transitionTo(Appointment::STATUS_CONFIRMED); $this->em->persist($appt); $this->em->flush(); @@ -87,7 +87,7 @@ class ServiceBasedSlotsTest extends ApiTestCase { $owner = $this->createUser(['ROLE_DOCTOR']); $doctor = new Doctor($owner, 'دکتر متا'); - $schedule = new WeeklySchedule($doctor, []); + $schedule = $this->newWeeklySchedule($doctor, []); // پیش‌فرض = اسلاتی $this->assertSame(WeeklySchedule::MODE_SLOT, $schedule->getMeta()['booking_mode']); diff --git a/tests/Appointment/ServiceModeSectionDurationTest.php b/tests/Appointment/ServiceModeSectionDurationTest.php index 87ab63f6..204a6a8c 100644 --- a/tests/Appointment/ServiceModeSectionDurationTest.php +++ b/tests/Appointment/ServiceModeSectionDurationTest.php @@ -24,7 +24,7 @@ class ServiceModeSectionDurationTest extends ApiTestCase $date = date('Y-m-d', strtotime('tomorrow')); $dayKey = (string) (((int) date('w', strtotime($date)) + 1) % 7); - $schedule = new WeeklySchedule($doctor, [ + $schedule = $this->newWeeklySchedule($doctor, [ $dayKey => ['sessions' => [[ 'active' => true, 'start_time' => '15:00', 'end_time' => '19:00', 'duration_per_patient' => 20, 'location_id' => 1, diff --git a/tests/Appointment/SlotUniquenessTest.php b/tests/Appointment/SlotUniquenessTest.php index 8867c884..20c0ecdf 100644 --- a/tests/Appointment/SlotUniquenessTest.php +++ b/tests/Appointment/SlotUniquenessTest.php @@ -27,7 +27,7 @@ class SlotUniquenessTest extends ApiTestCase private function newBooking(Doctor $doctor, int $start): Appointment { - return new Appointment($doctor, $this->createUser(), $start, $start + 1_800); + return $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800); } public function testTwoLiveBookingsOnSameSlotViolateUniqueKey(): void diff --git a/tests/Dashboard/DashboardChartPeriodTest.php b/tests/Dashboard/DashboardChartPeriodTest.php index cbd7bbe5..b4d5a580 100644 --- a/tests/Dashboard/DashboardChartPeriodTest.php +++ b/tests/Dashboard/DashboardChartPeriodTest.php @@ -88,7 +88,7 @@ class DashboardChartPeriodTest extends ApiTestCase $patient = $this->createUser(['ROLE_USER']); $start = time(); - $this->em->persist(new Appointment($doctor, $patient, $start, $start + 1_800)); + $this->em->persist($this->newAppointment($doctor, $patient, $start, $start + 1_800)); $this->em->flush(); $res = $this->authJson('GET', '/api/v1/dashboard/clinic', $owner); diff --git a/tests/Dashboard/DashboardTodayAppointmentsTest.php b/tests/Dashboard/DashboardTodayAppointmentsTest.php index 342e3f15..056f3600 100644 --- a/tests/Dashboard/DashboardTodayAppointmentsTest.php +++ b/tests/Dashboard/DashboardTodayAppointmentsTest.php @@ -24,7 +24,7 @@ class DashboardTodayAppointmentsTest extends ApiTestCase { // now() is guaranteed within [today midnight, tomorrow midnight-1] $start = time(); - $appt = new Appointment($doctor, $patient, $start, $start + 1_800); + $appt = $this->newAppointment($doctor, $patient, $start, $start + 1_800); $this->em->persist($appt); $this->em->flush(); diff --git a/tests/Database/UniqueConstraintsTest.php b/tests/Database/UniqueConstraintsTest.php index 7f79da94..242c79ad 100644 --- a/tests/Database/UniqueConstraintsTest.php +++ b/tests/Database/UniqueConstraintsTest.php @@ -47,8 +47,8 @@ class UniqueConstraintsTest extends ApiTestCase $this->em->flush(); $date = time(); - $this->em->persist(new DateOverride($doctor, $date)); - $this->em->persist(new DateOverride($doctor, $date)); + $this->em->persist($this->newDateOverride($doctor, $date)); + $this->em->persist($this->newDateOverride($doctor, $date)); $this->expectException(UniqueConstraintViolationException::class); $this->em->flush(); diff --git a/tests/Doctor/DoctorBookingStateAggregationTest.php b/tests/Doctor/DoctorBookingStateAggregationTest.php index 2f1efe5e..d639a9cb 100644 --- a/tests/Doctor/DoctorBookingStateAggregationTest.php +++ b/tests/Doctor/DoctorBookingStateAggregationTest.php @@ -60,8 +60,8 @@ class DoctorBookingStateAggregationTest extends ApiTestCase $this->em->persist($clinicAddress); $this->em->flush(); - $personal = new WeeklySchedule($doctor, $this->week($this->session(false, $personalAddress->getId()))); - $clinicSchedule = new WeeklySchedule( + $personal = $this->newWeeklySchedule($doctor, $this->week($this->session(false, $personalAddress->getId()))); + $clinicSchedule = $this->newWeeklySchedule( $doctor, $this->week($this->session(true, $clinicAddress->getId())), $clinic diff --git a/tests/Doctor/DoctorListBookableSortFilterTest.php b/tests/Doctor/DoctorListBookableSortFilterTest.php index 3cef449c..265e6f95 100644 --- a/tests/Doctor/DoctorListBookableSortFilterTest.php +++ b/tests/Doctor/DoctorListBookableSortFilterTest.php @@ -39,10 +39,10 @@ class DoctorListBookableSortFilterTest extends ApiTestCase $this->flagOff->setActiveDoctorAppointment(false); $days = [['sessions' => [['active' => true, 'start_time' => '09:00', 'end_time' => '13:00']]]]; - $this->em->persist(new WeeklySchedule($this->bookable, $days)); - $this->em->persist(new WeeklySchedule($this->flagOff, $days)); + $this->em->persist($this->newWeeklySchedule($this->bookable, $days)); + $this->em->persist($this->newWeeklySchedule($this->flagOff, $days)); - $disabled = new WeeklySchedule($this->bookingDisabled, $days); + $disabled = $this->newWeeklySchedule($this->bookingDisabled, $days); $disabled->setMeta(['online_booking_enabled' => false]); $this->em->persist($disabled); diff --git a/tests/Patient/AutoCreateSessionOnConfirmTest.php b/tests/Patient/AutoCreateSessionOnConfirmTest.php index b76066b6..a3e5243a 100644 --- a/tests/Patient/AutoCreateSessionOnConfirmTest.php +++ b/tests/Patient/AutoCreateSessionOnConfirmTest.php @@ -47,8 +47,7 @@ class AutoCreateSessionOnConfirmTest extends ApiTestCase private function makeAppointment(Doctor $doctor, ?Clinic $clinic = null): Appointment { - $appointment = new Appointment($doctor, $this->createUser(['ROLE_USER']), 1_790_000_000, 1_790_001_800); - $appointment->setClinic($clinic); + $appointment = $this->newAppointment($doctor, $this->createUser(['ROLE_USER']), 1_790_000_000, 1_790_001_800, $clinic); $appointment->transitionTo(Appointment::STATUS_CONFIRMED); $this->em->persist($appointment); $this->em->flush(); @@ -110,7 +109,7 @@ class AutoCreateSessionOnConfirmTest extends ApiTestCase $this->confirmation()->onConfirmed($first); $this->em->flush(); - $second = new Appointment($doctor, $patient, 1_790_100_000, 1_790_101_800); + $second = $this->newAppointment($doctor, $patient, 1_790_100_000, 1_790_101_800); $second->transitionTo(Appointment::STATUS_CONFIRMED); $this->em->persist($second); $this->em->flush(); diff --git a/tests/Patient/ClinicRecordAccessTest.php b/tests/Patient/ClinicRecordAccessTest.php index 4607739f..879c6e39 100644 --- a/tests/Patient/ClinicRecordAccessTest.php +++ b/tests/Patient/ClinicRecordAccessTest.php @@ -61,8 +61,7 @@ class ClinicRecordAccessTest extends ApiTestCase $this->em->persist($record); $start = strtotime('+60 days') + random_int(0, 500_000) * 7; - $appointment = new Appointment($doctor, $patient, $start, $start + 900); - $appointment->setClinic($clinic); + $appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic); $this->em->persist($appointment); $this->em->flush(); @@ -221,8 +220,7 @@ class ClinicRecordAccessTest extends ApiTestCase $patient = $this->createUser(); $start = strtotime('+70 days') + random_int(0, 500_000) * 7; - $appointment = new Appointment($doctor, $patient, $start, $start + 900); - $appointment->setClinic($clinic); + $appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic); $appointment->setVisitPriceRials(3_000_000); $this->em->persist($appointment); $this->em->flush(); diff --git a/tests/Patient/PatientAppointmentsTest.php b/tests/Patient/PatientAppointmentsTest.php index 561095e9..16bb23c2 100644 --- a/tests/Patient/PatientAppointmentsTest.php +++ b/tests/Patient/PatientAppointmentsTest.php @@ -31,7 +31,7 @@ class PatientAppointmentsTest extends ApiTestCase private function appointment(Doctor $doctor, PatientRecord $record, int $slotStart): Appointment { - $appt = new Appointment($doctor, $record->getUser(), $slotStart, $slotStart + 1800); + $appt = $this->newAppointment($doctor, $record->getUser(), $slotStart, $slotStart + 1800); $this->em->persist($appt); $this->em->flush(); diff --git a/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php b/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php index c3bb64c4..2e857d0a 100644 --- a/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php +++ b/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php @@ -37,7 +37,7 @@ class AppointmentPaidConfirmFilesSessionTest extends ApiTestCase $this->em->flush(); $slotStart = strtotime('+30 days') + random_int(0, 500_000) * 7; - $appointment = new Appointment($doctor, $this->createUser(['ROLE_USER']), $slotStart, $slotStart + 1_800); + $appointment = $this->newAppointment($doctor, $this->createUser(['ROLE_USER']), $slotStart, $slotStart + 1_800); // رزرو آنلاین با پنجرهٔ پرداخت ثبت می‌شود؛ پرداخت باید همین را پاک کند. $appointment->markPendingWithTtl(Appointment::PAYMENT_TTL); if ($withClinic !== null) { diff --git a/tests/Secretary/SecretaryAppointmentScopeTest.php b/tests/Secretary/SecretaryAppointmentScopeTest.php index 5d25f665..76aa1347 100644 --- a/tests/Secretary/SecretaryAppointmentScopeTest.php +++ b/tests/Secretary/SecretaryAppointmentScopeTest.php @@ -39,8 +39,8 @@ class SecretaryAppointmentScopeTest extends ApiTestCase // یک نوبت برای هر پزشک $patient = $this->createUser(['ROLE_USER']); $start = time() + 3600; - $this->em->persist(new Appointment($doctorA, $patient, $start, $start + 900)); - $this->em->persist(new Appointment($doctorB, $patient, $start + 1800, $start + 2700)); + $this->em->persist($this->newAppointment($doctorA, $patient, $start, $start + 900)); + $this->em->persist($this->newAppointment($doctorB, $patient, $start + 1800, $start + 2700)); $this->em->flush(); $body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser); @@ -66,7 +66,7 @@ class SecretaryAppointmentScopeTest extends ApiTestCase $patient = $this->createUser(['ROLE_USER']); $start = time() + 3600; - $this->em->persist(new Appointment($doctor, $patient, $start, $start + 900)); + $this->em->persist($this->newAppointment($doctor, $patient, $start, $start + 900)); $this->em->flush(); $body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser); diff --git a/tests/Secretary/SecretaryOnlineShareTest.php b/tests/Secretary/SecretaryOnlineShareTest.php index 4a8275bb..364b7bd2 100644 --- a/tests/Secretary/SecretaryOnlineShareTest.php +++ b/tests/Secretary/SecretaryOnlineShareTest.php @@ -62,8 +62,7 @@ class SecretaryOnlineShareTest extends ApiTestCase private function makeOnlinePayment(Doctor $doctor, ?Clinic $clinic = null): Payment { $start = strtotime('+30 days') + random_int(0, 500_000) * 7; - $appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900); - $appointment->setClinic($clinic); + $appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900, $clinic); $this->em->persist($appointment); $payment = new Payment($this->createUser(), self::GROSS, 'mock', Payment::TYPE_APPOINTMENT, '');