Files
clinicpro/tests/ApiTestCase.php
T
hamedandClaude Opus 5 d53874ff50 feat(tenant): mark the booking tables with their owning environment
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) <noreply@anthropic.com>
2026-07-28 11:22:37 +03:30

190 lines
6.3 KiB
PHP

<?php
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;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Base class for functional API tests.
*
* Provides a booted KernelBrowser, the EntityManager, and helpers to create
* users and issue JWTs so tests can hit authenticated /api and /oauth endpoints.
* Runs against the dedicated db_test database (see .env.test / doctrine when@test).
*/
abstract class ApiTestCase extends WebTestCase
{
protected KernelBrowser $client;
protected EntityManagerInterface $em;
protected function setUp(): void
{
$this->client = static::createClient();
$this->em = static::getContainer()->get(EntityManagerInterface::class);
$this->ensureFreePlan();
}
/**
* The «free» plan is SubscriptionService::getEffectivePlan's fallback for any
* tenant without a paid subscription. Without it every hasFeature() is false
* and the patient / service / insurance endpoints answer 403 instead of doing
* their job. db_test is never reset, so the insert is idempotent.
*
* Mirrors the row shipped in the dev database.
*/
private function ensureFreePlan(): void
{
$repo = $this->em->getRepository(SubscriptionPlan::class);
if ($repo->findOneBy(['name' => 'free']) !== null) {
return;
}
$this->em->persist(new SubscriptionPlan('free', 0, 1, [
'patient_records' => true,
'services' => true,
'sms_panel' => true,
'insurance' => true,
]));
$this->em->flush();
}
/**
* Persist a user with the given roles. Mobile is randomised per test to
* avoid unique-constraint clashes across cases without a full DB reset.
*/
protected function createUser(array $roles = ['ROLE_USER'], ?string $mobile = null): User
{
// 9 random digits after 09 (full ^09\d{9}$ space) — db_test is never reset,
// so a narrower space eventually collides on the unique mobile.
$mobile ??= '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$user = new User($mobile);
$user->setRoles($roles);
$user->setStatus(1);
$this->em->persist($user);
$this->em->flush();
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()
->get(JWTTokenManagerInterface::class)
->create($user);
}
/**
* Issue an authenticated JSON request and return the decoded response body.
*/
protected function authJson(string $method, string $uri, User $user, array $body = []): array
{
$this->client->request(
$method,
$uri,
server: [
'HTTP_AUTHORIZATION' => 'Bearer ' . $this->jwtFor($user),
'CONTENT_TYPE' => 'application/json',
],
content: $body ? json_encode($body) : null,
);
return json_decode($this->client->getResponse()->getContent(), true) ?? [];
}
protected function responseCode(): int
{
return $this->client->getResponse()->getStatusCode();
}
/**
* Count the SQL queries executed while running $fn. Used to assert that a
* list endpoint's query count does not grow with the number of rows (N+1).
*/
protected function countQueries(callable $fn): int
{
$holder = static::getContainer()->get('doctrine.debug_data_holder');
$holder->reset();
$fn();
return array_sum(array_map('count', $holder->getData()));
}
}