Task 01 planned a new `branches` table with `doctor_addresses.branch_id` bridging to it. That plan was wrong: the branch already exists and is called `DoctorAddress`. It carries name, address, telephone, coordinates, city/province FKs and an owner (`forDoctor` / `forClinic` + `type`), and the whole system already consumes it with exactly that meaning — `WeeklySchedule.sessions[].location_id` points at `doctor_addresses.id`, `appointment-booking-locations` calls each row a booking location, and nine CRUD endpoints plus four admin pages manage them. A parallel table would mean two sources of truth for one physical place and a branch that `location_id` never references. So no `branches` table and no duplicate branch CRUD. Only the three genuinely missing pieces: - `doctor_addresses.active` / `.timezone`, both NOT NULL with a default so existing rows need no backfill and no current behaviour changes. `active` is stored only — applying it to slot calculation is task 03, since touching `SlotCalculatorService` is off limits in this phase. - `branch_working_hours`, keyed to `doctor_addresses.id`. Minutes from midnight rather than "09:00" strings so range intersection stays arithmetic. PUT replaces all seven days; validation of the whole week runs before any DELETE, so an invalid sixth day cannot wipe the five valid ones and then answer 422. - `rooms`, with `capacity` as concurrency (a three-bed injection room is one resource with capacity 3, not three resources) and a deletion-guard iterator so tasks 02 and 07 can add reasons without editing RoomService. `BranchWorkingHours` first registered as an aggregate child of `DoctorAddress`; TenantSchemaCoverageTest rejected it correctly, because that root is itself declared global. It now carries a real tenant pair instead, derived in the constructor from the address's `type` — a total mapping, and the address is only ever listed in its own context, so nothing is hidden wrongly. RoomController checks ownership explicitly rather than trusting TenantFilter: hard isolation only applies to a *chosen* context, so a doctor who had not selected one could PATCH another clinic's room. Caught by RoomCrudTest::testForeignRoomIsNotFound, which failed with 200 before the fix. 35 tests, 97 assertions. Slot-mode frozen contract still green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
145 lines
5.4 KiB
PHP
145 lines
5.4 KiB
PHP
<?php
|
||
|
||
namespace App\Tests\Branch;
|
||
|
||
use App\Doctor\Entity\DoctorAddress;
|
||
|
||
/**
|
||
* دو ویژگی تازهٔ شعبه (`active` / `timezone`) و فهرست شعبههای محیط جاری.
|
||
*/
|
||
class BranchFieldsTest extends BranchTestCase
|
||
{
|
||
/** ردیفهای موجود بدون backfill درست میشوند؛ هیچ رفتار فعلی عوض نمیشود. */
|
||
public function testExistingBranchGetsSafeDefaults(): void
|
||
{
|
||
[$user, , $address] = $this->doctorWithAddress();
|
||
|
||
self::assertTrue($address->isActive());
|
||
self::assertSame(DoctorAddress::DEFAULT_TIMEZONE, $address->getTimezone());
|
||
|
||
$body = $this->authJson('GET', '/api/v1/branches', $user);
|
||
|
||
self::assertSame(200, $this->responseCode());
|
||
self::assertTrue($body['data'][0]['active']);
|
||
self::assertSame('Asia/Tehran', $body['data'][0]['timezone']);
|
||
}
|
||
|
||
public function testListReportsWorkingHoursAndRoomCounts(): void
|
||
{
|
||
[$user, , $address] = $this->doctorWithAddress();
|
||
|
||
$before = $this->authJson('GET', '/api/v1/branches', $user);
|
||
self::assertFalse($before['data'][0]['working_hours_defined']);
|
||
self::assertSame(0, $before['data'][0]['rooms_count']);
|
||
|
||
$this->authJson('PUT', "/api/v1/branch/{$address->getUuid()}/working-hours", $user, [
|
||
'days' => [1 => [['start_minute' => 540, 'end_minute' => 780]]],
|
||
]);
|
||
$this->authJson('POST', '/api/v1/room', $user, [
|
||
'address_uuid' => $address->getUuid(),
|
||
'name' => 'اتاق ۱',
|
||
]);
|
||
|
||
$after = $this->authJson('GET', '/api/v1/branches', $user);
|
||
|
||
self::assertTrue($after['data'][0]['working_hours_defined']);
|
||
self::assertSame(1, $after['data'][0]['rooms_count']);
|
||
}
|
||
|
||
/** فقط اتاق فعال شمرده میشود — اتاق غیرفعال ظرفیت واقعی شعبه نیست. */
|
||
public function testInactiveRoomIsNotCounted(): void
|
||
{
|
||
[$user, , $address] = $this->doctorWithAddress();
|
||
$room = $this->authJson('POST', '/api/v1/room', $user, [
|
||
'address_uuid' => $address->getUuid(),
|
||
'name' => 'اتاق بسته',
|
||
]);
|
||
$this->authJson('PATCH', "/api/v1/room/{$room['data']['uuid']}", $user, ['active' => false]);
|
||
|
||
$body = $this->authJson('GET', '/api/v1/branches', $user);
|
||
|
||
self::assertSame(0, $body['data'][0]['rooms_count']);
|
||
}
|
||
|
||
public function testListShowsOnlyTheCurrentContextBranches(): void
|
||
{
|
||
[$doctorUser, , $doctorAddress] = $this->doctorWithAddress('مطب شخصی');
|
||
$this->clinicWithAddress('شعبهٔ کلینیک بیگانه');
|
||
|
||
$body = $this->authJson('GET', '/api/v1/branches', $doctorUser);
|
||
|
||
self::assertCount(1, $body['data']);
|
||
self::assertSame($doctorAddress->getUuid(), $body['data'][0]['uuid']);
|
||
}
|
||
|
||
public function testBranchIsDeactivated(): void
|
||
{
|
||
[$user, , $address] = $this->doctorWithAddress();
|
||
|
||
$body = $this->authJson('PATCH', "/api/v1/branch/{$address->getUuid()}", $user, ['active' => false]);
|
||
|
||
self::assertSame(200, $this->responseCode());
|
||
self::assertFalse($body['data']['active']);
|
||
}
|
||
|
||
public function testTimezoneIsUpdated(): void
|
||
{
|
||
[$user, , $address] = $this->doctorWithAddress();
|
||
|
||
$body = $this->authJson('PATCH', "/api/v1/branch/{$address->getUuid()}", $user, [
|
||
'timezone' => 'Asia/Dubai',
|
||
]);
|
||
|
||
self::assertSame(200, $this->responseCode());
|
||
self::assertSame('Asia/Dubai', $body['data']['timezone']);
|
||
}
|
||
|
||
/** با DateTimeZone::listIdentifiers سنجیده میشود، نه با regex. */
|
||
public function testUnknownTimezoneIsRejected(): void
|
||
{
|
||
[$user, , $address] = $this->doctorWithAddress();
|
||
|
||
$body = $this->authJson('PATCH', "/api/v1/branch/{$address->getUuid()}", $user, ['timezone' => 'Tehran']);
|
||
|
||
self::assertSame(422, $this->responseCode());
|
||
self::assertSame('timezone', $body['errors'][0]['field']);
|
||
}
|
||
|
||
public function testForeignBranchCannotBePatched(): void
|
||
{
|
||
[$doctorUser] = $this->doctorWithAddress();
|
||
[, , $foreignAddress] = $this->clinicWithAddress();
|
||
|
||
$this->authJson('PATCH', "/api/v1/branch/{$foreignAddress->getUuid()}", $doctorUser, ['active' => false]);
|
||
|
||
self::assertSame(404, $this->responseCode());
|
||
}
|
||
|
||
/** شمارشها گروهیاند: تعداد کوئریها با تعداد شعبهها رشد نمیکند. */
|
||
public function testListQueryCountDoesNotGrowWithBranches(): void
|
||
{
|
||
// یک کرنل برای هر دو اندازهگیری، وگرنه reboot دادهٔ کوئریها را میریزد.
|
||
$this->client->disableReboot();
|
||
|
||
[$user, $doctor] = $this->doctorWithAddress();
|
||
|
||
$queriesForOne = $this->countQueries(
|
||
fn () => $this->authJson('GET', '/api/v1/branches', $user)
|
||
);
|
||
|
||
for ($i = 0; $i < 4; $i++) {
|
||
$extra = DoctorAddress::forDoctor($doctor);
|
||
$extra->setName("شعبهٔ $i");
|
||
$this->em->persist($extra);
|
||
}
|
||
$this->em->flush();
|
||
|
||
$queriesForFive = $this->countQueries(
|
||
fn () => $this->authJson('GET', '/api/v1/branches', $user)
|
||
);
|
||
|
||
self::assertCount(5, json_decode($this->client->getResponse()->getContent(), true)['data']);
|
||
self::assertSame($queriesForOne, $queriesForFive);
|
||
}
|
||
}
|