Three pages, all on the existing design system: BranchesPage lists the current environment's booking locations with their working-hours and active-room counts, and two subpages edit the week and the rooms. The list page deliberately does not create or rename a branch — clinic and doctor detail pages already do that, and duplicating it would give one physical place two edit surfaces. Route permission reuses `appointment_settings` rather than inventing a new one. Two real bugs fell out of exercising this end to end: `days` was serialising as a JSON *array*, not an object keyed "0".."6" — keys 0..6 are sequential so json_encode collapses them to a list. The client reads days["0"] either way, so nothing looked broken, but the response shape was unstable: one missing day would flip the same field to an object. The controller now casts to stdClass and WorkingHoursTest::testDaysIsAJsonObjectNotAnArray pins it. Found by curling the endpoint for the docs, not by any test. `<input type="time">` caps at 23:59, so it can neither display nor produce the legal end value 1440. An all-day range would have vanished from the form and been corrupted by the first save. Ranges now carry an explicit end-of-day flag, with a round-trip test proving 1440 survives. docs/api/branch.md documents all eight endpoints with responses captured from real curl runs against ddev, including the 422 and 404 bodies. doctor.md records that active/timezone now appear on all nine existing address endpoints (additive), and tenancy.md gains the two lessons this task taught: an aggregate child whose root is itself declared global inherits no environment and needs a real pair, and TenantFilter is not a substitute for an explicit ownership check because hard isolation only applies to a *chosen* context. Verified: phpunit 1067 tests / 2974 assertions green; slot-mode frozen contract green; phpstan 14 errors before and after, none in touched files; tsc clean; vitest 87 files / 612 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
230 lines
9.3 KiB
PHP
230 lines
9.3 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());
|
|
}
|
|
|
|
/**
|
|
* `days` باید **شیء** JSON با کلیدهای "0".."6" باشد، نه آرایه.
|
|
* کلیدهای ۰..۶ پشتسرهماند و json_encode بیمراقبت آرایه میساخت؛ کلاینت
|
|
* `days["0"]` هر دو را میخواند، ولی شکل پاسخ با جا افتادن یک روز عوض میشد.
|
|
*/
|
|
public function testDaysIsAJsonObjectNotAnArray(): void
|
|
{
|
|
[$user, , $address] = $this->doctorWithAddress();
|
|
$this->put($user, $address->getUuid(), [0 => [['start_minute' => 540, 'end_minute' => 780]]]);
|
|
|
|
foreach (['PUT', 'GET'] as $method) {
|
|
if ($method === 'GET') {
|
|
$this->authJson('GET', "/api/v1/branch/{$address->getUuid()}/working-hours", $user);
|
|
}
|
|
|
|
$raw = json_decode($this->client->getResponse()->getContent(), false);
|
|
self::assertInstanceOf(\stdClass::class, $raw->data->days, "$method: days باید شیء باشد");
|
|
// get_object_vars نامِ عددیِ ویژگیها را به int برمیگرداند؛ آنچه مهم است
|
|
// stdClass بودن بالا سنجیده شد. اینجا فقط کامل بودن هفت روز.
|
|
self::assertSame(range(0, 6), array_keys(get_object_vars($raw->data->days)));
|
|
}
|
|
}
|
|
|
|
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']);
|
|
}
|
|
}
|