feat(subscription): implement resource quota management based on subscription plans

This commit is contained in:
hamed
2026-08-04 19:38:49 +03:30
parent 0dca245246
commit 3e7028d77a
16 changed files with 408 additions and 18 deletions
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\Tests\Resource;
use App\Resource\Entity\ClinicResource;
use App\Shared\Constant\ErrorCodes;
use App\Subscription\Entity\SubscriptionPlan;
/**
* سقف منابع بر اساس پلن اشتراک: free یک منبع، basic سه، professional بی‌نهایت.
*
* سقف روی همهٔ منابع محیط اعمال می‌شود — فعال و غیرفعال — وگرنه یک بار
* غیرفعال‌کردن، سقف را دور می‌زد.
*/
class ResourceQuotaTest extends ResourceTestCase
{
public function testFreePlanAllowsExactlyOneResource(): void
{
$this->setPlanResourceQuota(1);
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$this->createResource($user, $address, $type, ['name' => 'اتاق ۱']);
self::assertSame(201, $this->responseCode());
$body = $this->createResource($user, $address, $type, ['name' => 'اتاق ۲']);
self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame(ErrorCodes::ERR_RESOURCE_LIMIT_001, $body['errors'][0]['code']);
}
public function testBasicPlanAllowsThreeResourcesAndRejectsTheFourth(): void
{
$this->setPlanResourceQuota(3);
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
foreach (['اتاق ۱', 'اتاق ۲', 'اتاق ۳'] as $name) {
$this->createResource($user, $address, $type, ['name' => $name]);
self::assertSame(201, $this->responseCode(), $name);
}
$body = $this->createResource($user, $address, $type, ['name' => 'اتاق ۴']);
self::assertSame(422, $this->responseCode());
self::assertSame(ErrorCodes::ERR_RESOURCE_LIMIT_001, $body['errors'][0]['code']);
self::assertStringContainsString('۳', $this->toPersianDigits($body['errors'][0]['message']));
}
public function testProfessionalPlanIsUnlimited(): void
{
$this->setPlanResourceQuota(SubscriptionPlan::UNLIMITED);
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
foreach (range(1, 5) as $i) {
$this->createResource($user, $address, $type, ['name' => "اتاق $i"]);
self::assertSame(201, $this->responseCode(), "resource #$i");
}
}
/** غیرفعال‌کردن منبع، جای خالی نمی‌سازد. */
public function testDeactivatedResourcesStillCountTowardTheQuota(): void
{
$this->setPlanResourceQuota(1);
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$first = $this->createResource($user, $address, $type, ['name' => 'اتاق ۱']);
$this->authJson('PATCH', '/api/v1/resource/' . $first['data']['uuid'], $user, ['active' => false]);
self::assertSame(200, $this->responseCode());
$this->createResource($user, $address, $type, ['name' => 'اتاق ۲']);
self::assertSame(422, $this->responseCode());
}
/** حذف منبع، جا را واقعاً آزاد می‌کند — شمارش زندهٔ دیتابیس است نه شمارندهٔ ذخیره‌شده. */
public function testDeletingAResourceFreesTheSlot(): void
{
$this->setPlanResourceQuota(1);
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$first = $this->createResource($user, $address, $type, ['name' => 'اتاق ۱']);
$this->authJson('DELETE', '/api/v1/resource/' . $first['data']['uuid'], $user);
self::assertSame(200, $this->responseCode());
$this->createResource($user, $address, $type, ['name' => 'اتاق ۲']);
self::assertSame(201, $this->responseCode());
}
/** سقف مالِ همان محیط است؛ پر شدن یک کلینیک، کلینیک دیگر را قفل نمی‌کند. */
public function testQuotaIsCountedPerTenantPair(): void
{
$this->setPlanResourceQuota(1);
[$userA, , $addressA] = $this->clinicWithAddress();
$this->createResource($userA, $addressA, $this->resourceType($addressA), ['name' => 'اتاق A']);
self::assertSame(201, $this->responseCode());
[$userB, , $addressB] = $this->clinicWithAddress('شعبهٔ کلینیک دوم');
$this->createResource($userB, $addressB, $this->resourceType($addressB), ['name' => 'اتاق B']);
self::assertSame(201, $this->responseCode());
self::assertSame(1, $this->em->getRepository(ClinicResource::class)->countForPair(
$addressB->tenantEntityType(),
$addressB->tenantEntityId(),
));
}
private function toPersianDigits(string $value): string
{
return strtr($value, ['0' => '۰', '1' => '۱', '2' => '۲', '3' => '۳', '4' => '۴',
'5' => '۵', '6' => '۶', '7' => '۷', '8' => '۸', '9' => '۹']);
}
}