Files
clinicpro/tests/Resource/ResourceCrudTest.php
T
hamedandClaude Opus 5 ab4974d174 feat(resource): every resource is supervised by a doctor
Supervision now lives on the resource itself instead of being asked for again at
booking time, so one relation answers it everywhere.

The column is deliberately separate from the existing doctor_id bridge. That
bridge means "this resource IS this doctor" and isPerson() uses it to pin
capacity at 1; a supervised three-seat device must not become a person resource.
The FK is SET NULL rather than CASCADE because deleting a doctor should not take
the clinic's laser with it.

Required on create and non-clearable on update, enforced in the API where it can
give a Persian message. Ownership is checked through Clinic::hasDoctor so a
secretary cannot put their device under a doctor of another clinic; that returns
404, not 403, keeping foreign data invisible.

The 13 existing resources are backfilled deterministically: a practice resource
gets its own doctor, a clinic resource gets that clinic's first doctor. Both are
editable from the resource form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:15:42 +03:30

286 lines
12 KiB
PHP

<?php
namespace App\Tests\Resource;
use App\Resource\Entity\ClinicResource;
class ResourceCrudTest extends ResourceTestCase
{
public function testResourceIsCreatedWithTenantPairDerivedFromTheAddress(): void
{
[$user, $clinic, $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$body = $this->createResource($user, $address, $type, ['capacity' => 2, 'setup_minutes' => 5]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame(2, $body['data']['capacity']);
self::assertSame(5, $body['data']['setup_minutes']);
self::assertSame($address->getUuid(), $body['data']['address_uuid']);
self::assertNull($body['data']['subject_kind'], 'منبع بدون پل یعنی دستگاه');
$resource = $this->em->getRepository(ClinicResource::class)->findOneBy(['uuid' => $body['data']['uuid']]);
self::assertSame('clinic', $resource->getEntityType());
self::assertSame($clinic->getId(), $resource->getEntityId());
}
public function testDefaultsAreOneCapacityAndZeroBuffers(): void
{
[$user, , $address] = $this->clinicWithAddress();
$body = $this->createResource($user, $address, $this->resourceType($address));
self::assertSame(1, $body['data']['capacity']);
self::assertSame(0, $body['data']['setup_minutes']);
self::assertSame(0, $body['data']['cleanup_minutes']);
self::assertTrue($body['data']['active']);
}
public function testZeroCapacityIsRejected(): void
{
[$user, , $address] = $this->clinicWithAddress();
$body = $this->createResource($user, $address, $this->resourceType($address), ['capacity' => 0]);
self::assertSame(422, $this->responseCode());
self::assertSame('capacity', $body['errors'][0]['field']);
}
/** پزشک هم‌زمان دو بیمار ندارد — قید در خودِ entity است، نه فقط در سرویس. */
public function testCapacityAboveOneOnAPersonTypeIsRejected(): void
{
[$user, , $address] = $this->clinicWithAddress();
$doctorType = $this->resourceType($address, 'doctor', 'پزشک');
$body = $this->createResource($user, $address, $doctorType, ['capacity' => 2]);
self::assertSame(422, $this->responseCode());
self::assertSame('capacity', $body['errors'][0]['field']);
self::assertStringContainsString('شخص', $body['errors'][0]['message']);
}
/** ۰ و ۱۰ هر دو معتبرند: آماده‌سازی می‌تواند صفر باشد و تمیزکاری نباشد یا برعکس. */
public function testZeroSetupWithNonZeroCleanupIsValid(): void
{
[$user, , $address] = $this->clinicWithAddress();
$body = $this->createResource($user, $address, $this->resourceType($address), [
'setup_minutes' => 0,
'cleanup_minutes' => 10,
]);
self::assertSame(201, $this->responseCode());
self::assertSame(10, $body['data']['cleanup_minutes']);
}
public function testUnknownAttributeKeyIsAcceptedButNonScalarValueIsNot(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$ok = $this->createResource($user, $address, $type, [
'attributes' => ['gender' => 'female', 'anything_else' => 42],
]);
self::assertSame(201, $this->responseCode(), 'کلید ناشناخته عمداً پذیرفته می‌شود');
self::assertSame('female', $ok['data']['attributes']['gender']);
self::assertSame(42, $ok['data']['attributes']['anything_else']);
$bad = $this->createResource($user, $address, $type, [
'name' => 'دستگاه دوم',
'attributes' => ['nested' => ['a' => 1]],
]);
self::assertSame(422, $this->responseCode());
self::assertSame('attributes', $bad['errors'][0]['field']);
}
public function testAttributeKeyMustBeSnakeCase(): void
{
[$user, , $address] = $this->clinicWithAddress();
$body = $this->createResource($user, $address, $this->resourceType($address), [
'attributes' => ['Device Model' => 'x'],
]);
self::assertSame(422, $this->responseCode());
self::assertSame('attributes', $body['errors'][0]['field']);
}
public function testForeignAddressIsNotFound(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
[, , $foreignAddress] = $this->clinicWithAddress('شعبهٔ بیگانه');
// ناظر معتبرِ محیط خودی می‌آید تا خطای آدرسِ بیگانه سنجیده شود، نه خطای فیلد کم.
$this->authJson('POST', '/api/v1/resource', $user, [
'address_uuid' => $foreignAddress->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => 'دستگاه',
'supervisor_doctor_uuid' => $this->supervisorFor($address)->getUuid(),
]);
self::assertSame(404, $this->responseCode());
}
/**
* هر منبع زیر نظر یک پزشک است — صفحهٔ نوبت‌ها تب منابع را زیر همان پزشک می‌چیند،
* پس منبعِ بی‌ناظر جایی برای دیده‌شدن ندارد.
*/
public function testSupervisorIsRequiredOnCreate(): void
{
[$user, , $address] = $this->clinicWithAddress();
$body = $this->authJson('POST', '/api/v1/resource', $user, [
'address_uuid' => $address->getUuid(),
'type_uuid' => $this->resourceType($address)->getUuid(),
'name' => 'دستگاه بی‌ناظر',
]);
self::assertSame(422, $this->responseCode());
self::assertSame('supervisor_doctor_uuid', $body['errors'][0]['field']);
}
public function testSupervisorIsReturnedOnTheResource(): void
{
[$user, , $address] = $this->clinicWithAddress();
$supervisor = $this->supervisorFor($address);
$body = $this->createResource($user, $address, $this->resourceType($address));
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame($supervisor->getUuid(), $body['data']['supervisor']['uuid']);
self::assertSame($supervisor->getName(), $body['data']['supervisor']['name']);
}
/** ناظر پلِ منبع نیست: دستگاه با ناظر نباید «منبع انسانی» شود و ظرفیتش قفل بماند. */
public function testSupervisorDoesNotTurnADeviceIntoAPersonResource(): void
{
[$user, , $address] = $this->clinicWithAddress();
$body = $this->createResource($user, $address, $this->resourceType($address), ['capacity' => 3]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame(3, $body['data']['capacity']);
self::assertNull($body['data']['subject_kind'], 'ناظر نباید پل شمرده شود');
}
/** منشیِ یک کلینیک نباید دستگاهش را زیر نظر پزشک کلینیک دیگری ببرد. */
public function testForeignSupervisorIsNotFound(): void
{
[$user, , $address] = $this->clinicWithAddress();
[, , $foreignAddress] = $this->clinicWithAddress('کلینیک بیگانه');
$foreignDoctor = $this->supervisorFor($foreignAddress);
$this->authJson('POST', '/api/v1/resource', $user, [
'address_uuid' => $address->getUuid(),
'type_uuid' => $this->resourceType($address)->getUuid(),
'name' => 'دستگاه',
'supervisor_doctor_uuid' => $foreignDoctor->getUuid(),
]);
self::assertSame(404, $this->responseCode());
}
/** تغییر ناظر مجاز است، برداشتنش نه. */
public function testSupervisorCannotBeCleared(): void
{
[$user, , $address] = $this->clinicWithAddress();
$created = $this->createResource($user, $address, $this->resourceType($address));
$body = $this->authJson('PATCH', "/api/v1/resource/{$created['data']['uuid']}", $user, [
'supervisor_doctor_uuid' => '',
]);
self::assertSame(422, $this->responseCode());
self::assertSame('supervisor_doctor_uuid', $body['errors'][0]['field']);
}
public function testForeignResourceIsNotFound(): void
{
[$ownerUser, , $ownerAddress] = $this->clinicWithAddress();
$created = $this->createResource($ownerUser, $ownerAddress, $this->resourceType($ownerAddress));
[$otherUser] = $this->clinicWithAddress('شعبهٔ دیگر');
$this->authJson('GET', "/api/v1/resource/{$created['data']['uuid']}", $otherUser);
self::assertSame(404, $this->responseCode());
$this->authJson('PATCH', "/api/v1/resource/{$created['data']['uuid']}", $otherUser, ['name' => 'دزدیده‌شده']);
self::assertSame(404, $this->responseCode());
$this->authJson('DELETE', "/api/v1/resource/{$created['data']['uuid']}", $otherUser);
self::assertSame(404, $this->responseCode());
}
public function testResourceIsUpdatedAndDeleted(): void
{
[$user, , $address] = $this->clinicWithAddress();
$created = $this->createResource($user, $address, $this->resourceType($address));
$body = $this->authJson('PATCH', "/api/v1/resource/{$created['data']['uuid']}", $user, [
'name' => 'لیزر دو',
'cleanup_minutes' => 15,
'active' => false,
]);
self::assertSame(200, $this->responseCode());
self::assertSame('لیزر دو', $body['data']['name']);
self::assertSame(15, $body['data']['cleanup_minutes']);
self::assertFalse($body['data']['active']);
$this->authJson('DELETE', "/api/v1/resource/{$created['data']['uuid']}", $user);
self::assertSame(200, $this->responseCode());
$this->authJson('GET', "/api/v1/resource/{$created['data']['uuid']}", $user);
self::assertSame(404, $this->responseCode());
}
public function testListIsFilteredByAddressTypeAndActive(): void
{
[$user, $clinic, $address] = $this->clinicWithAddress();
$second = $this->extraAddress($clinic);
$device = $this->resourceType($address, 'device', 'دستگاه');
$chair = $this->resourceType($address, 'chair', 'صندلی');
$this->createResource($user, $address, $device, ['name' => 'دستگاه شعبهٔ یک']);
$this->createResource($user, $second, $device, ['name' => 'دستگاه شعبهٔ دو']);
$off = $this->createResource($user, $address, $chair, ['name' => 'صندلی خاموش']);
$this->authJson('PATCH', "/api/v1/resource/{$off['data']['uuid']}", $user, ['active' => false]);
$all = $this->authJson('GET', '/api/v1/resources', $user);
self::assertCount(3, $all['data']);
$byAddress = $this->authJson('GET', "/api/v1/resources?address_uuid={$address->getUuid()}", $user);
self::assertCount(2, $byAddress['data']);
$byType = $this->authJson('GET', "/api/v1/resources?type_uuid={$device->getUuid()}", $user);
self::assertCount(2, $byType['data']);
$activeOnly = $this->authJson('GET', '/api/v1/resources?active=1', $user);
self::assertCount(2, $activeOnly['data']);
}
public function testListShowsOnlyTheCurrentEnvironment(): void
{
[$user, , $address] = $this->clinicWithAddress();
$this->createResource($user, $address, $this->resourceType($address));
[$otherUser, , $otherAddress] = $this->clinicWithAddress('شعبهٔ دیگر');
$this->createResource($otherUser, $otherAddress, $this->resourceType($otherAddress));
$body = $this->authJson('GET', '/api/v1/resources', $user);
self::assertCount(1, $body['data']);
}
public function testBlankNameIsRejected(): void
{
[$user, , $address] = $this->clinicWithAddress();
$body = $this->createResource($user, $address, $this->resourceType($address), ['name' => ' ']);
self::assertSame(422, $this->responseCode());
self::assertSame('name', $body['errors'][0]['field']);
}
}