feat(subscription): add resource quota management for subscription plans and update related components
This commit is contained in:
@@ -14,6 +14,16 @@ export function formatNumber(n: number): string {
|
||||
return new Intl.NumberFormat('fa-IR').format(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* سقف منابعِ یک پلن اشتراک به زبان آدم — `-1` یعنی نامحدود.
|
||||
*
|
||||
* سه صفحه همین عدد را نشان میدهند (منابع، اشتراک، اشتراکِ ادمین)؛ یک جا نوشته میشود
|
||||
* تا «نامحدود» در یکی از آنها به «-۱» تبدیل نشود.
|
||||
*/
|
||||
export function formatResourceLimit(max: number): string {
|
||||
return max < 0 ? 'نامحدود' : formatNumber(max);
|
||||
}
|
||||
|
||||
// سال شمسی: رقم فارسی بدون جداکنندهی هزارگان («۱۴۰۴» نه «۱٬۴۰۴»).
|
||||
export function formatYear(year: number): string {
|
||||
return new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(year);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import AdminSubscriptionPage from './AdminSubscriptionPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3, max_resources: 3,
|
||||
features: { patient_records: true, services: true, sms_panel: false }, active: true, periods: [],
|
||||
},
|
||||
{
|
||||
uuid: 'p-pro', name: 'professional', level: 2, max_secretaries: 10, max_resources: -1,
|
||||
features: { patient_records: true, services: true, sms_panel: true }, active: true, periods: [],
|
||||
},
|
||||
];
|
||||
|
||||
function mockApi() {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url.includes('/admin/subscription/plans')) return Promise.resolve({ success: true, data: PLANS });
|
||||
return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0 } });
|
||||
});
|
||||
patch.mockResolvedValue({ success: true, data: PLANS[0] });
|
||||
}
|
||||
|
||||
/** مودالِ ویرایشِ پلنِ دادهشده را باز میکند. هر کارت یک دکمهٔ مداد دارد. */
|
||||
async function openEdit(planLabel: string) {
|
||||
const card = (await screen.findByText(planLabel)).closest('.card') as HTMLElement;
|
||||
const buttons = within(card).getAllByRole('button');
|
||||
fireEvent.click(buttons[buttons.length - 1]);
|
||||
}
|
||||
|
||||
describe('AdminSubscriptionPage — سقف منابع پلن', () => {
|
||||
beforeEach(() => { get.mockReset(); patch.mockReset(); mockApi(); });
|
||||
|
||||
it('سقف منابع هر پلن را روی کارتش نشان میدهد', async () => {
|
||||
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||
|
||||
expect(await screen.findByText('حداکثر ۳ منبع')).toBeInTheDocument();
|
||||
expect(screen.getByText('حداکثر نامحدود منبع')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('فرم ویرایش با مقدار فعلی پر میشود و همان را میفرستد', async () => {
|
||||
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||
await openEdit('پایه');
|
||||
|
||||
const input = await screen.findByLabelText('حداکثر منبع *');
|
||||
expect(input).toHaveValue('3');
|
||||
|
||||
fireEvent.change(input, { target: { value: '5' } });
|
||||
fireEvent.click(screen.getByText('ذخیره'));
|
||||
|
||||
await waitFor(() => expect(patch).toHaveBeenCalled());
|
||||
expect(patch.mock.calls[0][0]).toBe('/api/v1/admin/subscription/plan/p-basic');
|
||||
expect(patch.mock.calls[0][1]).toMatchObject({ max_resources: 5 });
|
||||
});
|
||||
|
||||
/** ادمین «۱-» تایپ نمیکند؛ سوییچ همان قرارداد را میسازد. */
|
||||
it('سوییچ «منابع نامحدود» مقدار ۱- میفرستد', async () => {
|
||||
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||
await openEdit('پایه');
|
||||
|
||||
await screen.findByLabelText('حداکثر منبع *');
|
||||
fireEvent.click(screen.getByText('منابع نامحدود'));
|
||||
fireEvent.click(screen.getByText('ذخیره'));
|
||||
|
||||
await waitFor(() => expect(patch).toHaveBeenCalled());
|
||||
expect(patch.mock.calls[0][1]).toMatchObject({ max_resources: -1 });
|
||||
});
|
||||
|
||||
it('پلن نامحدود با سوییچِ روشن باز میشود و فیلد عددیاش قفل است', async () => {
|
||||
renderWithProviders(<AdminSubscriptionPage />, { route: '/admin/admin-subscription' });
|
||||
await openEdit('حرفهای');
|
||||
|
||||
expect(await screen.findByLabelText('حداکثر منبع *')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { PaginatedResponse } from '../lib/api';
|
||||
import type { SubscriptionPlan, SubscriptionPeriod } from '../types';
|
||||
import { formatRial, formatNumber, formatDate } from '../lib/utils';
|
||||
import { formatRial, formatNumber, formatDate, formatResourceLimit } from '../lib/utils';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
@@ -35,11 +35,23 @@ const planSchema = z.object({
|
||||
name: z.string().min(1, 'نام الزامی است'),
|
||||
level: z.coerce.number().min(0),
|
||||
max_secretaries: z.coerce.number().min(1),
|
||||
// API نامحدود را با `-1` میفهمد، ولی فرم عددِ منفی نمیگیرد: `numericField` علامت را
|
||||
// پاک میکند و «۱-» هم چیزی نیست که ادمین حدس بزند. پس یک سوییچ، و نگاشت هنگام ارسال.
|
||||
resources_unlimited: z.boolean(),
|
||||
max_resources: z.coerce.number().int().min(1),
|
||||
features: z.record(z.string(), z.boolean()),
|
||||
active: z.boolean(),
|
||||
});
|
||||
type PlanForm = z.infer<typeof planSchema>;
|
||||
|
||||
/** بدنهٔ واقعی API: سوییچِ «نامحدود» به همان `-1` قراردادی برمیگردد. */
|
||||
type PlanPayload = Omit<PlanForm, 'resources_unlimited'>;
|
||||
|
||||
const toPlanPayload = ({ resources_unlimited, max_resources, ...rest }: PlanForm): PlanPayload => ({
|
||||
...rest,
|
||||
max_resources: resources_unlimited ? -1 : max_resources,
|
||||
});
|
||||
|
||||
const periodSchema = z.object({
|
||||
plan_uuid: z.string().min(1, 'پلن الزامی است'),
|
||||
label: z.string().min(1, 'عنوان دوره الزامی است'),
|
||||
@@ -105,19 +117,21 @@ function PlansTab() {
|
||||
defaultValues: { features: emptyFeatures(), active: true },
|
||||
});
|
||||
|
||||
const unlimitedResources = planForm.watch('resources_unlimited');
|
||||
|
||||
const periodForm = useForm<PeriodForm>({
|
||||
resolver: zodResolver(periodSchema),
|
||||
defaultValues: { is_trial: false, sort_order: 0, active: true, price_rials: 0 },
|
||||
});
|
||||
|
||||
const createPlanMut = useMutation({
|
||||
mutationFn: (body: PlanForm) => api.post('/api/v1/admin/subscription/plan', body),
|
||||
mutationFn: (body: PlanPayload) => api.post('/api/v1/admin/subscription/plan', body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPlanModal(null); planForm.reset(); toast.success('پلن ایجاد شد'); },
|
||||
onError: (e: any) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const updatePlanMut = useMutation({
|
||||
mutationFn: ({ uuid, body }: { uuid: string; body: PlanForm }) => api.patch(`/api/v1/admin/subscription/plan/${uuid}`, body),
|
||||
mutationFn: ({ uuid, body }: { uuid: string; body: PlanPayload }) => api.patch(`/api/v1/admin/subscription/plan/${uuid}`, body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-subscription-plans'] }); setPlanModal(null); toast.success('پلن بروزرسانی شد'); },
|
||||
onError: (e: any) => toast.error(e.message),
|
||||
});
|
||||
@@ -145,6 +159,10 @@ function PlansTab() {
|
||||
name: plan.name,
|
||||
level: plan.level,
|
||||
max_secretaries: plan.max_secretaries,
|
||||
// سقفِ نامحدود عددی برای نمایش ندارد؛ ۱ مینشیند تا خاموشکردن سوییچ یک مقدار
|
||||
// معتبر بدهد، نه یک فیلدِ خالی.
|
||||
resources_unlimited: plan.max_resources < 0,
|
||||
max_resources: plan.max_resources < 0 ? 1 : plan.max_resources,
|
||||
features: { ...emptyFeatures(), ...plan.features },
|
||||
active: (plan as any).active ?? true,
|
||||
});
|
||||
@@ -173,7 +191,7 @@ function PlansTab() {
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
<button className="btn primary sm" onClick={() => { planForm.reset({ features: emptyFeatures(), active: true, level: 0, max_secretaries: 1 }); setPlanModal('create'); }}>
|
||||
<button className="btn primary sm" onClick={() => { planForm.reset({ features: emptyFeatures(), active: true, level: 0, max_secretaries: 1, max_resources: 1, resources_unlimited: false }); setPlanModal('create'); }}>
|
||||
<PlusIcon style={{ width: 16 }} /> پلن جدید
|
||||
</button>
|
||||
</div>
|
||||
@@ -194,6 +212,7 @@ function PlansTab() {
|
||||
<span className={`badge ${plan.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>{plan.active ? 'فعال' : 'غیرفعال'}</span>
|
||||
<span className="badge blue" style={{ fontSize: 11 }}>سطح {plan.level}</span>
|
||||
<span className="muted" style={{ fontSize: 12 }}>حداکثر {plan.max_secretaries} منشی</span>
|
||||
<span className="muted" style={{ fontSize: 12 }}>حداکثر {formatResourceLimit(plan.max_resources)} منبع</span>
|
||||
<span style={{ fontSize: 12, display: 'flex', gap: 6 }}>
|
||||
{Object.entries(plan.features).map(([k, v]) => (
|
||||
<span key={k} className={`badge ${v ? 'green' : 'gray'}`} style={{ fontSize: 10 }}>{FEATURE_LABELS[k] ?? k}</span>
|
||||
@@ -260,8 +279,9 @@ function PlansTab() {
|
||||
title={planModal === 'create' ? 'پلن جدید' : 'ویرایش پلن'}
|
||||
>
|
||||
<form onSubmit={planForm.handleSubmit((d) => {
|
||||
if (planModal === 'create') createPlanMut.mutate(d);
|
||||
else if (planModal !== null && typeof planModal === 'object') updatePlanMut.mutate({ uuid: planModal.uuid, body: d });
|
||||
const body = toPlanPayload(d);
|
||||
if (planModal === 'create') createPlanMut.mutate(body);
|
||||
else if (planModal !== null && typeof planModal === 'object') updatePlanMut.mutate({ uuid: planModal.uuid, body });
|
||||
})}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div className="field">
|
||||
@@ -279,6 +299,23 @@ function PlansTab() {
|
||||
<input {...numericField(planForm.register('max_secretaries'))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="plan-max-resources">حداکثر منبع *</label>
|
||||
<input
|
||||
id="plan-max-resources"
|
||||
{...numericField(planForm.register('max_resources'))}
|
||||
disabled={unlimitedResources}
|
||||
style={unlimitedResources ? { opacity: 0.5 } : undefined}
|
||||
/>
|
||||
<label style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: 13.5, cursor: 'pointer', marginTop: 8 }}>
|
||||
<span style={{ color: 'var(--text)' }}>منابع نامحدود</span>
|
||||
<SwitchToggle {...planForm.register('resources_unlimited')} />
|
||||
</label>
|
||||
<span className="field-hint">سقف اتاق و دستگاه و پرسنلِ هر محیط. منابعِ پزشک و پرسنل هم در این شمارش میآیند.</span>
|
||||
{planForm.formState.errors.max_resources && (
|
||||
<span className="field-error">{planForm.formState.errors.max_resources.message}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="field-label">قابلیتهای پلن</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface)', overflow: 'hidden' }}>
|
||||
|
||||
@@ -15,20 +15,20 @@ import SubscriptionPage from './SubscriptionPage';
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const PLANS = [
|
||||
{ uuid: 'p-free', name: 'free', level: 0, max_secretaries: 1,
|
||||
{ uuid: 'p-free', name: 'free', level: 0, max_secretaries: 1, max_resources: 1,
|
||||
features: { patient_records: false, services: false, sms_panel: false }, active: true, periods: [] },
|
||||
{ uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3,
|
||||
{ uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3, max_resources: 3,
|
||||
features: { patient_records: true, services: true, sms_panel: false }, active: true,
|
||||
periods: [
|
||||
{ uuid: 'per-1m', label: 'یک ماهه', duration_months: 1, price_rials: 1000000, is_trial: false },
|
||||
{ uuid: 'per-12m', label: 'یک ساله', duration_months: 12, price_rials: 9000000, is_trial: false },
|
||||
] },
|
||||
{ uuid: 'p-pro', name: 'professional', level: 2, max_secretaries: 10,
|
||||
{ uuid: 'p-pro', name: 'professional', level: 2, max_secretaries: 10, max_resources: -1,
|
||||
features: { patient_records: true, services: true, sms_panel: true }, active: true, periods: [] },
|
||||
];
|
||||
|
||||
const DEFAULT_MY = {
|
||||
subscription: { plan: { name: 'basic', level: 1, max_secretaries: 3, features: {} }, is_trial: false, days_remaining: 25 },
|
||||
subscription: { plan: { name: 'basic', level: 1, max_secretaries: 3, max_resources: 3, features: {} }, is_trial: false, days_remaining: 25 },
|
||||
used_trial: true, effective_plan: null,
|
||||
};
|
||||
|
||||
@@ -54,6 +54,16 @@ describe('SubscriptionPage', () => {
|
||||
expect(proCard.getByText('محبوبترین')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** سقف منابع کنار سقف منشی روی هر کارت پلن مینشیند؛ `-1` یعنی «نامحدود». */
|
||||
it('shows the resource quota on every plan card', async () => {
|
||||
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
|
||||
|
||||
await screen.findByText('پلن پایه');
|
||||
expect(within(screen.getByTestId('plan-card-free')).getByText('۱ منبع')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('plan-card-basic')).getByText('۳ منبع')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('plan-card-professional')).getByText('نامحدود منبع')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the current-plan card without an expiry warning when the plan is healthy', async () => {
|
||||
renderWithProviders(<SubscriptionPage />, { route: '/admin/subscription' });
|
||||
expect(await screen.findByText('پلن فعلی شما:')).toBeInTheDocument();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CreditCardIcon } from '@heroicons/react/24/outline';
|
||||
import { CreditCardIcon, CubeIcon } from '@heroicons/react/24/outline';
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { SubscriptionPlan, MySubscriptionData, SubscriptionPeriod } from '../types';
|
||||
import { usePaymentConfig } from '../hooks/usePaymentConfig';
|
||||
import { formatRial, formatNumber, formatDate } from '../lib/utils';
|
||||
import { formatRial, formatNumber, formatDate, formatResourceLimit } from '../lib/utils';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
import {
|
||||
@@ -413,8 +413,8 @@ function PlanCard({
|
||||
{meta.desc}
|
||||
</div>
|
||||
|
||||
{/* Secretaries pill */}
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
{/* Secretaries + resources pills — دو سقفِ عددیِ پلن، کنار هم */}
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end', gap: 6, flexWrap: 'wrap' }}>
|
||||
<div dir="rtl" style={{
|
||||
minWidth: 86, height: 32, borderRadius: 29, background: 'var(--surface-3)',
|
||||
color: 'var(--text)', display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'center',
|
||||
@@ -425,6 +425,16 @@ function PlanCard({
|
||||
</span>
|
||||
<PlanCardPerson size={18} />
|
||||
</div>
|
||||
<div dir="rtl" style={{
|
||||
minWidth: 86, height: 32, borderRadius: 29, background: 'var(--surface-3)',
|
||||
color: 'var(--text)', display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 12px',
|
||||
}}>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, whiteSpace: 'nowrap' }}>
|
||||
{formatResourceLimit(plan.max_resources)} منبع
|
||||
</span>
|
||||
<CubeIcon style={{ width: 17 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period toggle */}
|
||||
|
||||
@@ -191,6 +191,10 @@ callback درگاه پرداخت — پس از پرداخت موفق، `ClinicSu
|
||||
|
||||
`max_resources` اختیاری است و پیشفرضش `1` — پلنِ ناشناخته نباید بیصدا نامحدود شود. مقدار `-1` یعنی نامحدود. در `PATCH` هم همین فیلد پذیرفته میشود.
|
||||
|
||||
مقدارهای پذیرفتهشده: `-1` یا هر عدد مثبت. `0` و هر منفیِ دیگر → `422 ERR_VALIDATION_001` با پیام «max_resources باید عددی مثبت باشد یا -1 برای نامحدود».
|
||||
|
||||
پنل ادمین (`/admin/admin-subscription`) بهجای گرفتنِ `-1` از کاربر، یک سوییچ «منابع نامحدود» دارد و خودش همان `-1` را میفرستد. تست: `tests/Subscription/AdminPlanResourceLimitTest.php`.
|
||||
|
||||
**خطاها:**
|
||||
|
||||
| کد | HTTP | شرح |
|
||||
|
||||
@@ -130,6 +130,31 @@ class SubscriptionController extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* سقف منابع فقط `-1` (نامحدود) یا عددی مثبت است.
|
||||
*
|
||||
* `0` پلنی میساخت که هیچ منبعی نمیدهد و هر مقدار منفیِ دیگری هم مثل `-1` رفتار
|
||||
* میکرد بیآنکه چیزی در پنل نشانش بدهد — هر دو خاموش و گیجکنندهاند.
|
||||
*/
|
||||
private function rejectInvalidResourceLimit(array $data): ?JsonResponse
|
||||
{
|
||||
if (!isset($data['max_resources'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$max = (int) $data['max_resources'];
|
||||
|
||||
if ($max === 0 || $max < SubscriptionPlan::UNLIMITED) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
'max_resources باید عددی مثبت باشد یا -1 برای نامحدود',
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/subscription/plan', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function adminCreatePlan(Request $request): JsonResponse
|
||||
@@ -145,6 +170,10 @@ class SubscriptionController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پلنی با این نام از قبل وجود دارد', 422);
|
||||
}
|
||||
|
||||
if (($error = $this->rejectInvalidResourceLimit($data)) !== null) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$plan = new SubscriptionPlan(
|
||||
$name,
|
||||
(int) $data['level'],
|
||||
@@ -169,6 +198,10 @@ class SubscriptionController extends BaseController
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (($error = $this->rejectInvalidResourceLimit($data)) !== null) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
if (isset($data['name'])) {
|
||||
$existing = $this->planRepo->findByName(trim($data['name']));
|
||||
if ($existing !== null && $existing->getUuid() !== $plan->getUuid()) {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Subscription;
|
||||
|
||||
use App\Subscription\Entity\SubscriptionPlan;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* سقف منابعِ پلن از پنل ادمین نوشته و خوانده میشود.
|
||||
*
|
||||
* `-1` یعنی نامحدود؛ `0` و هر منفیِ دیگری رد میشود — پلنی که هیچ منبعی نمیدهد یا
|
||||
* منفیِ بیمعنا دارد، خاموش شکست میخورد و در پنل چیزی نشان نمیدهد.
|
||||
*/
|
||||
class AdminPlanResourceLimitTest extends ApiTestCase
|
||||
{
|
||||
private function planName(): string
|
||||
{
|
||||
return 'quota_plan_' . bin2hex(random_bytes(4));
|
||||
}
|
||||
|
||||
public function testPlanIsCreatedWithItsResourceQuota(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/admin/subscription/plan', $admin, [
|
||||
'name' => $this->planName(),
|
||||
'level' => 7,
|
||||
'max_secretaries' => 2,
|
||||
'max_resources' => 5,
|
||||
'features' => ['services' => true],
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(5, $body['data']['max_resources']);
|
||||
}
|
||||
|
||||
/** نیامدنِ فیلد یعنی محافظهکارانهترین سقف، نه نامحدود. */
|
||||
public function testOmittedQuotaDefaultsToOne(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/admin/subscription/plan', $admin, [
|
||||
'name' => $this->planName(),
|
||||
'level' => 7,
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame(1, $body['data']['max_resources']);
|
||||
}
|
||||
|
||||
public function testQuotaIsUpdatedAndUnlimitedIsAccepted(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/admin/subscription/plan', $admin, [
|
||||
'name' => $this->planName(),
|
||||
'level' => 7,
|
||||
'max_resources' => 3,
|
||||
]);
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/admin/subscription/plan/' . $created['data']['uuid'], $admin, [
|
||||
'max_resources' => SubscriptionPlan::UNLIMITED,
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(SubscriptionPlan::UNLIMITED, $body['data']['max_resources']);
|
||||
}
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('invalidQuotas')]
|
||||
public function testInvalidQuotaIsRejected(int $quota): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
|
||||
$this->authJson('POST', '/api/v1/admin/subscription/plan', $admin, [
|
||||
'name' => $this->planName(),
|
||||
'level' => 7,
|
||||
'max_resources' => $quota,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
/** @return array<string, array{0: int}> */
|
||||
public static function invalidQuotas(): array
|
||||
{
|
||||
return [
|
||||
'zero' => [0],
|
||||
'below unlimited' => [-2],
|
||||
];
|
||||
}
|
||||
|
||||
/** فهرست پلنها سقف را میدهد؛ وگرنه کارتهای صفحهٔ اشتراک چیزی برای نشاندادن ندارند. */
|
||||
public function testPlanListExposesTheQuota(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER']);
|
||||
$body = $this->authJson('GET', '/api/v1/subscription/plans', $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertArrayHasKey('max_resources', $body['data'][0]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user