From 1ce99575383fcb0f3c55f53ffd56940cd28e7157 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 19 Aug 2026 22:15:56 +0330 Subject: [PATCH] fix(admin): drop cached environment data when switching context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- assets/admin/hooks/useSubscription.test.tsx | 74 ++++++++++++++ assets/admin/hooks/useSubscription.ts | 6 +- assets/admin/pages/SelectContextPage.test.tsx | 48 +++++++++ assets/admin/pages/SelectContextPage.tsx | 5 + .../DualEnvironmentSubscriptionTest.php | 97 +++++++++++++++++++ 5 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 assets/admin/hooks/useSubscription.test.tsx create mode 100644 assets/admin/pages/SelectContextPage.test.tsx create mode 100644 tests/Subscription/DualEnvironmentSubscriptionTest.php diff --git a/assets/admin/hooks/useSubscription.test.tsx b/assets/admin/hooks/useSubscription.test.tsx new file mode 100644 index 00000000..51c9060e --- /dev/null +++ b/assets/admin/hooks/useSubscription.test.tsx @@ -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; +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 }) => ( + {children} + ); + + // محیط کلینیک از قبل در 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 }) => ( + {children} + ); + + 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(); + }); +}); diff --git a/assets/admin/hooks/useSubscription.ts b/assets/admin/hooks/useSubscription.ts index 4f0282b6..60a03dac 100644 --- a/assets/admin/hooks/useSubscription.ts +++ b/assets/admin/hooks/useSubscription.ts @@ -6,10 +6,14 @@ import type { MySubscriptionData } from '../types'; export function useSubscription() { const primaryRole = useAuthStore((s) => s.primaryRole); + const dbUuid = useAuthStore((s) => s.dbUuid); const enabled = primaryRole === 'doctor' || primaryRole === 'clinic' || primaryRole === 'secretary'; + // کلید شامل محیط فعال است: اشتراک روی محیط می‌نشیند، نه روی کاربر. پزشکی که هم + // مطب شخصی دارد و هم کلینیک، با کلیدِ بدون محیط پلنِ محیط قبلی را می‌دید و هر دو + // محیط ارتقایافته به‌نظر می‌رسیدند. const { data } = useQuery>({ - queryKey: ['subscription-my'], + queryKey: ['subscription-my', dbUuid], queryFn: () => api.get('/api/v1/subscription/my'), enabled, staleTime: 2 * 60 * 1000, diff --git a/assets/admin/pages/SelectContextPage.test.tsx b/assets/admin/pages/SelectContextPage.test.tsx new file mode 100644 index 00000000..0bfa1d9b --- /dev/null +++ b/assets/admin/pages/SelectContextPage.test.tsx @@ -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('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(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + fireEvent.click(screen.getByText('کلینیک تست')); + + await waitFor(() => expect(client.getQueryData(['subscription-my', 'doc-1'])).toBeUndefined()); + }); +}); diff --git a/assets/admin/pages/SelectContextPage.tsx b/assets/admin/pages/SelectContextPage.tsx index 6ffdd91f..31fdb629 100644 --- a/assets/admin/pages/SelectContextPage.tsx +++ b/assets/admin/pages/SelectContextPage.tsx @@ -1,5 +1,6 @@ import { useNavigate } from 'react-router'; import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useAuthStore, ContextItem } from '../stores/authStore'; const ROLE_LABELS: Record = { @@ -18,11 +19,15 @@ const TYPE_ICONS: Record = { export default function SelectContextPage() { const { availableContexts, switchContext } = useAuthStore(); const navigate = useNavigate(); + const qc = useQueryClient(); const [loading, setLoading] = useState(null); const handleSelect = async (ctx: ContextItem) => { setLoading(ctx.db_uuid); await switchContext(ctx.db_uuid); + // هر پاسخِ cache‌شده متعلق به محیط قبلی است — از اشتراک و پلن گرفته تا بیماران و + // نوبت‌ها. بدون پاک کردن، محیط تازه داده و دسترسی‌های محیط قبلی را نشان می‌دهد. + qc.clear(); navigate('/admin/dashboard', { replace: true }); }; diff --git a/tests/Subscription/DualEnvironmentSubscriptionTest.php b/tests/Subscription/DualEnvironmentSubscriptionTest.php new file mode 100644 index 00000000..22b1a39e --- /dev/null +++ b/tests/Subscription/DualEnvironmentSubscriptionTest.php @@ -0,0 +1,97 @@ +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']); + } +}