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>
173 lines
6.5 KiB
PHP
173 lines
6.5 KiB
PHP
<?php
|
||
|
||
namespace App\Tests\Branch;
|
||
|
||
use App\Branch\Entity\Room;
|
||
|
||
class RoomCrudTest extends BranchTestCase
|
||
{
|
||
/** @param array<string, mixed> $body */
|
||
private function createRoom(\App\Auth\Entity\User $user, string $addressUuid, array $body = []): array
|
||
{
|
||
return $this->authJson('POST', '/api/v1/room', $user, $body + [
|
||
'address_uuid' => $addressUuid,
|
||
'name' => 'اتاق تزریق',
|
||
]);
|
||
}
|
||
|
||
public function testRoomIsCreatedWithTenantPairDerivedFromTheBranch(): void
|
||
{
|
||
[$clinicUser, $clinic, $address] = $this->clinicWithAddress();
|
||
|
||
$body = $this->createRoom($clinicUser, $address->getUuid(), ['capacity' => 3, 'floor' => '2']);
|
||
|
||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||
self::assertSame(3, $body['data']['capacity']);
|
||
self::assertSame('2', $body['data']['floor']);
|
||
self::assertSame($address->getUuid(), $body['data']['address_uuid']);
|
||
|
||
$room = $this->em->getRepository(Room::class)->findOneBy(['uuid' => $body['data']['uuid']]);
|
||
self::assertSame('clinic', $room->getEntityType());
|
||
self::assertSame($clinic->getId(), $room->getEntityId());
|
||
}
|
||
|
||
public function testPersonalBranchRoomBelongsToTheDoctor(): void
|
||
{
|
||
[$doctorUser, $doctor, $address] = $this->doctorWithAddress();
|
||
|
||
$body = $this->createRoom($doctorUser, $address->getUuid());
|
||
|
||
self::assertSame(201, $this->responseCode());
|
||
|
||
$room = $this->em->getRepository(Room::class)->findOneBy(['uuid' => $body['data']['uuid']]);
|
||
self::assertSame('doctor', $room->getEntityType());
|
||
self::assertSame($doctor->getId(), $room->getEntityId());
|
||
}
|
||
|
||
public function testCapacityDefaultsToOne(): void
|
||
{
|
||
[$user, , $address] = $this->doctorWithAddress();
|
||
|
||
$body = $this->createRoom($user, $address->getUuid());
|
||
|
||
self::assertSame(1, $body['data']['capacity']);
|
||
self::assertTrue($body['data']['active']);
|
||
}
|
||
|
||
public function testZeroCapacityIsRejected(): void
|
||
{
|
||
[$user, , $address] = $this->doctorWithAddress();
|
||
|
||
$body = $this->createRoom($user, $address->getUuid(), ['capacity' => 0]);
|
||
|
||
self::assertSame(422, $this->responseCode());
|
||
self::assertSame('capacity', $body['errors'][0]['field']);
|
||
}
|
||
|
||
public function testBlankNameIsRejected(): void
|
||
{
|
||
[$user, , $address] = $this->doctorWithAddress();
|
||
|
||
$body = $this->authJson('POST', '/api/v1/room', $user, [
|
||
'address_uuid' => $address->getUuid(),
|
||
'name' => ' ',
|
||
]);
|
||
|
||
self::assertSame(422, $this->responseCode());
|
||
self::assertSame('name', $body['errors'][0]['field']);
|
||
}
|
||
|
||
public function testMissingAddressUuidIsRejected(): void
|
||
{
|
||
[$user] = $this->doctorWithAddress();
|
||
|
||
$body = $this->authJson('POST', '/api/v1/room', $user, ['name' => 'اتاق']);
|
||
|
||
self::assertSame(422, $this->responseCode());
|
||
self::assertSame('address_uuid', $body['errors'][0]['field']);
|
||
}
|
||
|
||
/** جفت محیط از آدرس میآید، پس نمیشود اتاق را روی شعبهٔ محیط دیگر نشاند. */
|
||
public function testRoomCannotBeCreatedOnAForeignBranch(): void
|
||
{
|
||
[$doctorUser] = $this->doctorWithAddress();
|
||
[, , $foreignAddress] = $this->clinicWithAddress();
|
||
|
||
$this->createRoom($doctorUser, $foreignAddress->getUuid());
|
||
|
||
self::assertSame(404, $this->responseCode());
|
||
}
|
||
|
||
public function testRoomIsUpdated(): void
|
||
{
|
||
[$user, , $address] = $this->doctorWithAddress();
|
||
$created = $this->createRoom($user, $address->getUuid());
|
||
|
||
$body = $this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $user, [
|
||
'name' => 'اتاق پانسمان',
|
||
'capacity' => 2,
|
||
'room_type' => 'پانسمان',
|
||
'active' => false,
|
||
]);
|
||
|
||
self::assertSame(200, $this->responseCode());
|
||
self::assertSame('اتاق پانسمان', $body['data']['name']);
|
||
self::assertSame(2, $body['data']['capacity']);
|
||
self::assertSame('پانسمان', $body['data']['room_type']);
|
||
self::assertFalse($body['data']['active']);
|
||
}
|
||
|
||
/** رشتهٔ خالی روی فیلد اختیاری یعنی «پاک کن»، نه ذخیرهٔ رشتهٔ خالی. */
|
||
public function testBlankOptionalFieldBecomesNull(): void
|
||
{
|
||
[$user, , $address] = $this->doctorWithAddress();
|
||
$created = $this->createRoom($user, $address->getUuid(), ['room_type' => 'تزریق']);
|
||
|
||
$body = $this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $user, ['room_type' => '']);
|
||
|
||
self::assertNull($body['data']['room_type']);
|
||
}
|
||
|
||
public function testRoomIsDeleted(): void
|
||
{
|
||
[$user, , $address] = $this->doctorWithAddress();
|
||
$created = $this->createRoom($user, $address->getUuid());
|
||
|
||
$this->authJson('DELETE', "/api/v1/room/{$created['data']['uuid']}", $user);
|
||
self::assertSame(200, $this->responseCode());
|
||
|
||
$this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $user, ['name' => 'x']);
|
||
self::assertSame(404, $this->responseCode());
|
||
}
|
||
|
||
public function testForeignRoomIsNotFound(): void
|
||
{
|
||
[$clinicUser, , $clinicAddress] = $this->clinicWithAddress();
|
||
$created = $this->createRoom($clinicUser, $clinicAddress->getUuid());
|
||
|
||
[$doctorUser] = $this->doctorWithAddress();
|
||
|
||
$this->authJson('PATCH', "/api/v1/room/{$created['data']['uuid']}", $doctorUser, ['name' => 'دزدیدهشده']);
|
||
self::assertSame(404, $this->responseCode());
|
||
|
||
$this->authJson('DELETE', "/api/v1/room/{$created['data']['uuid']}", $doctorUser);
|
||
self::assertSame(404, $this->responseCode());
|
||
}
|
||
|
||
public function testBranchRoomsAreListedForItsOwnerOnly(): void
|
||
{
|
||
[$user, , $address] = $this->doctorWithAddress();
|
||
$this->createRoom($user, $address->getUuid(), ['name' => 'اتاق ۱']);
|
||
$this->createRoom($user, $address->getUuid(), ['name' => 'اتاق ۲']);
|
||
|
||
$body = $this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/rooms", $user);
|
||
|
||
self::assertSame(200, $this->responseCode());
|
||
self::assertCount(2, $body['data']);
|
||
|
||
[, , $foreignAddress] = $this->clinicWithAddress();
|
||
$this->authJson('GET', "/api/v1/branch/{$foreignAddress->getUuid()}/rooms", $user);
|
||
self::assertSame(404, $this->responseCode());
|
||
}
|
||
}
|