Files
clinicpro/tests/Branch/WorkingHoursTest.php
T
hamedandClaude Opus 5 eebb363b9f feat(branch): branch working hours and rooms on the existing address entity
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>
2026-07-30 16:28:04 +03:30

207 lines
8.0 KiB
PHP

<?php
namespace App\Tests\Branch;
use App\Branch\Entity\BranchWorkingHours;
/**
* ساعت کاری هفتگی شعبه — GET/PUT روی /api/v1/branch/{addressUuid}/working-hours
*/
class WorkingHoursTest extends BranchTestCase
{
/** @param array<int, list<array{start_minute: int, end_minute: int}>> $days */
private function put(\App\Auth\Entity\User $user, string $addressUuid, array $days): array
{
return $this->authJson('PUT', "/api/v1/branch/$addressUuid/working-hours", $user, ['days' => $days]);
}
public function testEmptyBranchReportsSevenEmptyDaysAndUndefined(): void
{
[$user, , $address] = $this->doctorWithAddress();
$body = $this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/working-hours", $user);
self::assertSame(200, $this->responseCode());
self::assertFalse($body['data']['defined'], 'شعبهٔ بدون ساعت باید «تعریف‌نشده» باشد، نه همیشه‌باز');
self::assertSame(range(0, 6), array_map('intval', array_keys($body['data']['days'])));
foreach ($body['data']['days'] as $ranges) {
self::assertSame([], $ranges);
}
}
public function testFullWeekIsStoredAndReadBackIdentically(): void
{
[$user, , $address] = $this->doctorWithAddress();
$days = [];
foreach (range(0, 6) as $day) {
$days[$day] = [
['start_minute' => 540, 'end_minute' => 780], // 09:00-13:00
['start_minute' => 960, 'end_minute' => 1200], // 16:00-20:00
];
}
$written = $this->put($user, $address->getUuid(), $days);
self::assertSame(200, $this->responseCode(), json_encode($written, JSON_UNESCAPED_UNICODE));
self::assertTrue($written['data']['defined']);
$read = $this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/working-hours", $user);
self::assertSame($written['data']['days'], $read['data']['days']);
self::assertSame('09:00', $read['data']['days'][0][0]['start_time']);
self::assertSame('20:00', $read['data']['days'][0][1]['end_time']);
self::assertSame([0, 1], array_column($read['data']['days'][0], 'sequence'));
}
/** PUT قرارداد جایگزینی کامل دارد: آرایهٔ خالی یعنی شعبه بسته، نه «تغییری نده». */
public function testEmptyPayloadClosesTheBranch(): void
{
[$user, , $address] = $this->doctorWithAddress();
$this->put($user, $address->getUuid(), [3 => [['start_minute' => 600, 'end_minute' => 700]]]);
$body = $this->put($user, $address->getUuid(), []);
self::assertSame(200, $this->responseCode());
self::assertFalse($body['data']['defined']);
self::assertSame([], $body['data']['days'][3]);
}
public function testEndBeforeStartIsRejected(): void
{
[$user, , $address] = $this->doctorWithAddress();
$body = $this->put($user, $address->getUuid(), [0 => [['start_minute' => 800, 'end_minute' => 800]]]);
self::assertSame(422, $this->responseCode());
self::assertSame('end_minute', $body['errors'][0]['field']);
}
public function testOverlappingRangesInOneDayAreRejected(): void
{
[$user, , $address] = $this->doctorWithAddress();
$body = $this->put($user, $address->getUuid(), [2 => [
['start_minute' => 540, 'end_minute' => 780],
['start_minute' => 700, 'end_minute' => 900],
]]);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('هم‌پوشانی', $body['errors'][0]['message']);
}
/** بازهٔ چسبیده مجاز است: پایان یکی = شروع بعدی، هم‌پوشانی نیست. */
public function testTouchingRangesAreAccepted(): void
{
[$user, , $address] = $this->doctorWithAddress();
$this->put($user, $address->getUuid(), [2 => [
['start_minute' => 540, 'end_minute' => 780],
['start_minute' => 780, 'end_minute' => 900],
]]);
self::assertSame(200, $this->responseCode());
}
public function testAllDayRangeIsOneRow(): void
{
[$user, , $address] = $this->doctorWithAddress();
$body = $this->put($user, $address->getUuid(), [
5 => [['start_minute' => 0, 'end_minute' => BranchWorkingHours::MINUTES_IN_DAY]],
]);
self::assertSame(200, $this->responseCode());
self::assertCount(1, $body['data']['days'][5]);
self::assertSame('24:00', $body['data']['days'][5][0]['end_time']);
}
public function testMinuteBeyondOneDayIsRejected(): void
{
[$user, , $address] = $this->doctorWithAddress();
$this->put($user, $address->getUuid(), [1 => [['start_minute' => 0, 'end_minute' => 1441]]]);
self::assertSame(422, $this->responseCode());
}
public function testInvalidDayKeyIsRejected(): void
{
[$user, , $address] = $this->doctorWithAddress();
$body = $this->put($user, $address->getUuid(), [7 => [['start_minute' => 0, 'end_minute' => 60]]]);
self::assertSame(422, $this->responseCode());
self::assertSame('day_of_week', $body['errors'][0]['field']);
}
/**
* اتمی بودن: بازهٔ نامعتبر در روز ششم نباید روزهای درستِ قبل را پاک کند.
* بدون اعتبارسنجیِ کاملِ پیش از DELETE، این تست هفتهٔ ذخیره‌شده را خالی می‌بیند.
*/
public function testInvalidLaterDayLeavesTheStoredWeekUntouched(): void
{
[$user, , $address] = $this->doctorWithAddress();
$valid = [];
foreach (range(0, 6) as $day) {
$valid[$day] = [['start_minute' => 540, 'end_minute' => 780]];
}
$this->put($user, $address->getUuid(), $valid);
$broken = $valid;
$broken[5] = [['start_minute' => 900, 'end_minute' => 100]];
$this->put($user, $address->getUuid(), $broken);
self::assertSame(422, $this->responseCode());
$read = $this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/working-hours", $user);
self::assertTrue($read['data']['defined']);
foreach (range(0, 6) as $day) {
self::assertCount(1, $read['data']['days'][$day], "روز $day نباید پاک شده باشد");
}
}
public function testMissingDaysFieldIsRejected(): void
{
[$user, , $address] = $this->doctorWithAddress();
$body = $this->authJson('PUT', "/api/v1/branch/{$address->getUuid()}/working-hours", $user, ['x' => 1]);
self::assertSame(422, $this->responseCode());
self::assertSame('days', $body['errors'][0]['field']);
}
/** آدرس محیط دیگر: ۴۰۴ نه ۴۰۳ — وجود دادهٔ محیط بیگانه لو نمی‌رود. */
public function testForeignBranchIsNotFound(): void
{
[$doctorUser] = $this->doctorWithAddress();
[, , $foreignAddress] = $this->clinicWithAddress();
$this->authJson('GET', "/api/v1/branch/{$foreignAddress->getUuid()}/working-hours", $doctorUser);
self::assertSame(404, $this->responseCode());
}
public function testForeignBranchCannotBeWritten(): void
{
[$doctorUser] = $this->doctorWithAddress();
[, , $foreignAddress] = $this->clinicWithAddress();
$this->put($doctorUser, $foreignAddress->getUuid(), [0 => [['start_minute' => 0, 'end_minute' => 60]]]);
self::assertSame(404, $this->responseCode());
}
public function testClinicOwnerManagesItsOwnBranch(): void
{
[$clinicUser, , $address] = $this->clinicWithAddress();
$body = $this->put($clinicUser, $address->getUuid(), [
0 => [['start_minute' => 480, 'end_minute' => 1020]],
]);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame('08:00', $body['data']['days'][0][0]['start_time']);
}
}