fix(admin): drop cached environment data when switching context
A doctor who also owns a clinic runs two environments, and a subscription belongs to an environment, not to the user. The panel treated it as the user's: the subscription query was keyed ['subscription-my'] with no context, and switching environments never touched the react-query cache. So upgrading the clinic left the personal practice showing the clinic's plan with its feature-gated menu items unlocked, and vice versa. The query key now carries the active dbUuid, and the context switch clears the whole cache — every cached response belongs to the environment it was fetched in, not just this one. The API was already correct: DualEnvironmentSubscriptionTest pins that granting one environment leaves the other on free. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { makeClient } from '../test/utils';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({
|
||||||
|
api: { get: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { useSubscription } from './useSubscription';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
const initial = useAuthStore.getInitialState();
|
||||||
|
|
||||||
|
function planResponse(name: string) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
subscription: null,
|
||||||
|
used_trial: false,
|
||||||
|
effective_plan: { name, features: { patient_records: name !== 'free' }, max_secretaries: 1, max_resources: 1 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAuthStore.setState(initial, true);
|
||||||
|
get.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useSubscription', () => {
|
||||||
|
/**
|
||||||
|
* پزشکی که هم مطب شخصی دارد و هم کلینیک، دو محیط دارد و اشتراک روی محیط مینشیند.
|
||||||
|
* با کلیدِ بدون محیط، پاسخِ cache شدهٔ محیط قبلی در محیط تازه سرو میشد و هر دو
|
||||||
|
* محیط ارتقایافته بهنظر میرسیدند.
|
||||||
|
*/
|
||||||
|
it('پاسخِ cache شدهٔ محیط دیگر را سرو نمیکند', async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// محیط کلینیک از قبل در cache نشسته است.
|
||||||
|
client.setQueryData(['subscription-my', 'clinic-1'], planResponse('professional'));
|
||||||
|
|
||||||
|
useAuthStore.setState({ primaryRole: 'doctor', dbUuid: 'doctor-1' });
|
||||||
|
get.mockResolvedValue(planResponse('free'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSubscription(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.planLoaded).toBe(true));
|
||||||
|
expect(result.current.hasFeature('patient_records')).toBe(false);
|
||||||
|
expect(get).toHaveBeenCalledWith('/api/v1/subscription/my');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('در همان محیط، پاسخِ cache شده دوباره درخواست نمیشود', async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
client.setQueryData(['subscription-my', 'clinic-1'], planResponse('professional'));
|
||||||
|
useAuthStore.setState({ primaryRole: 'clinic', dbUuid: 'clinic-1' });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSubscription(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.hasFeature('patient_records')).toBe(true));
|
||||||
|
expect(get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,10 +6,14 @@ import type { MySubscriptionData } from '../types';
|
|||||||
|
|
||||||
export function useSubscription() {
|
export function useSubscription() {
|
||||||
const primaryRole = useAuthStore((s) => s.primaryRole);
|
const primaryRole = useAuthStore((s) => s.primaryRole);
|
||||||
|
const dbUuid = useAuthStore((s) => s.dbUuid);
|
||||||
const enabled = primaryRole === 'doctor' || primaryRole === 'clinic' || primaryRole === 'secretary';
|
const enabled = primaryRole === 'doctor' || primaryRole === 'clinic' || primaryRole === 'secretary';
|
||||||
|
|
||||||
|
// کلید شامل محیط فعال است: اشتراک روی محیط مینشیند، نه روی کاربر. پزشکی که هم
|
||||||
|
// مطب شخصی دارد و هم کلینیک، با کلیدِ بدون محیط پلنِ محیط قبلی را میدید و هر دو
|
||||||
|
// محیط ارتقایافته بهنظر میرسیدند.
|
||||||
const { data } = useQuery<ApiResponse<MySubscriptionData>>({
|
const { data } = useQuery<ApiResponse<MySubscriptionData>>({
|
||||||
queryKey: ['subscription-my'],
|
queryKey: ['subscription-my', dbUuid],
|
||||||
queryFn: () => api.get('/api/v1/subscription/my'),
|
queryFn: () => api.get('/api/v1/subscription/my'),
|
||||||
enabled,
|
enabled,
|
||||||
staleTime: 2 * 60 * 1000,
|
staleTime: 2 * 60 * 1000,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router';
|
||||||
|
import { makeClient } from '../test/utils';
|
||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
import SelectContextPage from './SelectContextPage';
|
||||||
|
|
||||||
|
vi.mock('react-router', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-router')>('react-router');
|
||||||
|
return { ...actual, useNavigate: () => vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
const initial = useAuthStore.getInitialState();
|
||||||
|
|
||||||
|
const CONTEXTS = [
|
||||||
|
{ db_uuid: 'doc-1', db_key: 'k1', type: 'doctor', role: 'doctor', name: 'مطب شخصی' },
|
||||||
|
{ db_uuid: 'clinic-1', db_key: 'k2', type: 'clinic', role: 'clinic', name: 'کلینیک تست' },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAuthStore.setState(initial, true);
|
||||||
|
useAuthStore.setState({ availableContexts: CONTEXTS as any, switchContext: vi.fn() as any });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SelectContextPage', () => {
|
||||||
|
/**
|
||||||
|
* هر پاسخِ cache شده متعلق به محیط قبلی است. اشتراک روی محیط مینشیند، پس بدون
|
||||||
|
* پاک کردن cache، مطب شخصی پلن کلینیک را نشان میداد و برعکس.
|
||||||
|
*/
|
||||||
|
it('بعد از تعویض محیط، cache کوئریها را پاک میکند', async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
client.setQueryData(['subscription-my', 'doc-1'], { stale: true });
|
||||||
|
|
||||||
|
render(<SelectContextPage />, {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('کلینیک تست'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(client.getQueryData(['subscription-my', 'doc-1'])).toBeUndefined());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuthStore, ContextItem } from '../stores/authStore';
|
import { useAuthStore, ContextItem } from '../stores/authStore';
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
@@ -18,11 +19,15 @@ const TYPE_ICONS: Record<string, string> = {
|
|||||||
export default function SelectContextPage() {
|
export default function SelectContextPage() {
|
||||||
const { availableContexts, switchContext } = useAuthStore();
|
const { availableContexts, switchContext } = useAuthStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
const [loading, setLoading] = useState<string | null>(null);
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSelect = async (ctx: ContextItem) => {
|
const handleSelect = async (ctx: ContextItem) => {
|
||||||
setLoading(ctx.db_uuid);
|
setLoading(ctx.db_uuid);
|
||||||
await switchContext(ctx.db_uuid);
|
await switchContext(ctx.db_uuid);
|
||||||
|
// هر پاسخِ cacheشده متعلق به محیط قبلی است — از اشتراک و پلن گرفته تا بیماران و
|
||||||
|
// نوبتها. بدون پاک کردن، محیط تازه داده و دسترسیهای محیط قبلی را نشان میدهد.
|
||||||
|
qc.clear();
|
||||||
navigate('/admin/dashboard', { replace: true });
|
navigate('/admin/dashboard', { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Subscription;
|
||||||
|
|
||||||
|
use App\Auth\Entity\User;
|
||||||
|
use App\Auth\Entity\UserActiveContext;
|
||||||
|
use App\Clinic\Entity\Clinic;
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Shared\Context\EntityContext;
|
||||||
|
use App\Subscription\Entity\SubscriptionPeriod;
|
||||||
|
use App\Subscription\Entity\SubscriptionPlan;
|
||||||
|
use App\Subscription\Service\SubscriptionService;
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A doctor who also owns a clinic runs two environments. Upgrading one must not
|
||||||
|
* upgrade the other: the subscription belongs to the environment, not the user.
|
||||||
|
*/
|
||||||
|
class DualEnvironmentSubscriptionTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
private function makeUserWithBothEnvironments(): array
|
||||||
|
{
|
||||||
|
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
|
||||||
|
|
||||||
|
$doctor = new Doctor($user, 'دکتر تست');
|
||||||
|
$this->em->persist($doctor);
|
||||||
|
|
||||||
|
$clinic = new Clinic($user);
|
||||||
|
$clinic->setName('کلینیک تست');
|
||||||
|
$this->em->persist($clinic);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return [$user, $doctor, $clinic];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makePeriod(): SubscriptionPeriod
|
||||||
|
{
|
||||||
|
$plan = new SubscriptionPlan('pro-' . bin2hex(random_bytes(4)), 5, 2, ['patient_records' => true]);
|
||||||
|
$this->em->persist($plan);
|
||||||
|
|
||||||
|
$period = new SubscriptionPeriod($plan, 'سه ماهه', 3, 1_000_000);
|
||||||
|
$this->em->persist($period);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $period;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function setActiveContext(User $user, string $dbUuid, string $dbType): void
|
||||||
|
{
|
||||||
|
$this->em->persist(new UserActiveContext($user, $dbUuid, $dbType));
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function service(): SubscriptionService
|
||||||
|
{
|
||||||
|
return static::getContainer()->get(SubscriptionService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGrantingTheClinicLeavesThePersonalPracticeOnFree(): void
|
||||||
|
{
|
||||||
|
[$user, $doctor, $clinic] = $this->makeUserWithBothEnvironments();
|
||||||
|
$period = $this->makePeriod();
|
||||||
|
|
||||||
|
$this->service()->grant('clinic', $clinic->getId(), $period->getUuid(), $user);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
$period->getPlan()->getId(),
|
||||||
|
$this->service()->getEffectivePlan('clinic', $clinic->getId())?->getId(),
|
||||||
|
);
|
||||||
|
self::assertSame('free', $this->service()->getEffectivePlan('doctor', $doctor->getId())?->getName());
|
||||||
|
self::assertNull($this->service()->getActiveSubscription('doctor', $doctor->getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMyReportsTheClinicPlanOnlyWhileStandingInTheClinic(): void
|
||||||
|
{
|
||||||
|
[$user, $doctor, $clinic] = $this->makeUserWithBothEnvironments();
|
||||||
|
$period = $this->makePeriod();
|
||||||
|
$this->service()->grant('clinic', $clinic->getId(), $period->getUuid(), $user);
|
||||||
|
|
||||||
|
$this->setActiveContext($user, $clinic->getUuid(), EntityContext::TYPE_CLINIC);
|
||||||
|
$body = $this->authJson('GET', '/api/v1/subscription/my', $user);
|
||||||
|
self::assertSame($period->getPlan()->getName(), $body['data']['effective_plan']['name']);
|
||||||
|
self::assertNotNull($body['data']['subscription']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMyFallsBackToFreeWhileStandingInThePersonalPractice(): void
|
||||||
|
{
|
||||||
|
[$user, $doctor, $clinic] = $this->makeUserWithBothEnvironments();
|
||||||
|
$period = $this->makePeriod();
|
||||||
|
$this->service()->grant('clinic', $clinic->getId(), $period->getUuid(), $user);
|
||||||
|
|
||||||
|
$this->setActiveContext($user, $doctor->getUuid(), EntityContext::TYPE_DOCTOR);
|
||||||
|
$body = $this->authJson('GET', '/api/v1/subscription/my', $user);
|
||||||
|
self::assertSame('free', $body['data']['effective_plan']['name']);
|
||||||
|
self::assertNull($body['data']['subscription']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user