feat(subscription): implement resource quota management based on subscription plans
This commit is contained in:
+30
-8
@@ -63,21 +63,43 @@ abstract class ApiTestCase extends WebTestCase
|
||||
* and the patient / service / insurance endpoints answer 403 instead of doing
|
||||
* their job. db_test is never reset, so the insert is idempotent.
|
||||
*
|
||||
* Mirrors the row shipped in the dev database.
|
||||
* Mirrors the row shipped in the dev database, except for max_resources: the
|
||||
* fixture grants an unlimited resource quota so that the dozens of suites which
|
||||
* merely need a room or a device are not rewritten into subscription tests. The
|
||||
* real per-plan quota is exercised explicitly by ResourceQuotaTest, which lowers
|
||||
* it with setPlanResourceQuota().
|
||||
*/
|
||||
private function ensureFreePlan(): void
|
||||
{
|
||||
$repo = $this->em->getRepository(SubscriptionPlan::class);
|
||||
if ($repo->findOneBy(['name' => 'free']) !== null) {
|
||||
$plan = $repo->findOneBy(['name' => 'free']);
|
||||
|
||||
if ($plan === null) {
|
||||
$this->em->persist(new SubscriptionPlan('free', 0, 1, [
|
||||
'patient_records' => true,
|
||||
'services' => true,
|
||||
'sms_panel' => true,
|
||||
'insurance' => true,
|
||||
], SubscriptionPlan::UNLIMITED));
|
||||
$this->em->flush();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->em->persist(new SubscriptionPlan('free', 0, 1, [
|
||||
'patient_records' => true,
|
||||
'services' => true,
|
||||
'sms_panel' => true,
|
||||
'insurance' => true,
|
||||
]));
|
||||
// db_test is never reset, so a quota lowered by a previous case must be undone.
|
||||
if ($plan->getMaxResources() !== SubscriptionPlan::UNLIMITED) {
|
||||
$plan->setMaxResources(SubscriptionPlan::UNLIMITED);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
||||
/** سقف منابعِ پلن مؤثرِ تستها؛ `SubscriptionPlan::UNLIMITED` یعنی بینهایت. */
|
||||
protected function setPlanResourceQuota(int $max, string $plan = 'free'): void
|
||||
{
|
||||
$entity = $this->em->getRepository(SubscriptionPlan::class)->findOneBy(['name' => $plan]);
|
||||
self::assertNotNull($entity, sprintf('Plan "%s" is missing from the test database.', $plan));
|
||||
|
||||
$entity->setMaxResources($max);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
|
||||
@@ -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' => '۹']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user