| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { beforeAll, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest'; |
|
|
| import { initTestI18n } from './helpers/i18n.mts'; |
|
|
| const openBillingPortal = vi.fn(async () => ({ outcome: 'opened' as const, url: 'https://portal' })); |
| const getSubscription = vi.fn<() => { planKey: string } | null>(() => null); |
|
|
| vi.mock('@/services/billing', async (importOriginal) => ({ |
| ...(await importOriginal<typeof import('@/services/billing')>()), |
| openBillingPortal: (...args: unknown[]) => openBillingPortal(...(args as [])), |
| getSubscription: () => getSubscription(), |
| })); |
|
|
| const { PanelGateReason, resolveGateAction } = await import('@/services/panel-gating'); |
|
|
| const PRO_ORIGIN = 'https://worldmonitor.app'; |
|
|
| let openSpy: MockInstance<typeof window.open>; |
| let reloadSpy: MockInstance<() => void>; |
| let openAuthModal: Mock<() => void>; |
| |
| const reservedTab = { closed: false, location: { href: '' } } as unknown as Window; |
|
|
| beforeAll(async () => { |
| await initTestI18n(); |
| }); |
|
|
| beforeEach(() => { |
| openAuthModal = vi.fn<() => void>(); |
| openSpy = vi.spyOn(window, 'open').mockReturnValue(reservedTab); |
| reloadSpy = vi.spyOn(window.location, 'reload').mockImplementation(() => {}); |
| openBillingPortal.mockClear(); |
| getSubscription.mockReset().mockReturnValue(null); |
| }); |
|
|
| |
| |
| |
|
|
| const act = (reason: (typeof PanelGateReason)[keyof typeof PanelGateReason], planKey?: string | null) => |
| resolveGateAction(reason, { openAuthModal, ...(planKey === undefined ? {} : { planKey }) }); |
|
|
| describe('resolveGateAction β resolution is inert', () => { |
| it('resolving a reason performs no side effect until the callback runs', () => { |
| for (const reason of Object.values(PanelGateReason)) act(reason); |
|
|
| expect(openSpy).not.toHaveBeenCalled(); |
| expect(openAuthModal).not.toHaveBeenCalled(); |
| expect(reloadSpy).not.toHaveBeenCalled(); |
| expect(openBillingPortal).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|
| describe('resolveGateAction β ANONYMOUS', () => { |
| it('opens the injected auth modal and navigates nowhere', () => { |
| act(PanelGateReason.ANONYMOUS)(); |
|
|
| expect(openAuthModal).toHaveBeenCalledTimes(1); |
| expect(openSpy).not.toHaveBeenCalled(); |
| expect(reloadSpy).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|
| describe('resolveGateAction β FREE_TIER', () => { |
| it('opens the pricing page on an absolute origin, in a new tab, with noopener', () => { |
| act(PanelGateReason.FREE_TIER)(); |
|
|
| |
| |
| expect(openSpy).toHaveBeenCalledWith(`${PRO_ORIGIN}/pro`, '_blank', 'noopener,noreferrer'); |
| expect(openAuthModal).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|
| describe.each([ |
| ['PAYMENT_ON_HOLD', () => PanelGateReason.PAYMENT_ON_HOLD], |
| ['RENEWAL_FAILED', () => PanelGateReason.RENEWAL_FAILED], |
| ])('resolveGateAction β %s', (_label, reason) => { |
| it('reserves the portal tab synchronously, before awaiting the portal session', () => { |
| act(reason())(); |
|
|
| |
| |
| expect(openSpy).toHaveBeenCalledTimes(1); |
| expect(openSpy).toHaveBeenCalledWith('', '_blank', 'noopener,noreferrer'); |
| expect(openSpy.mock.invocationCallOrder[0]!).toBeLessThan( |
| openBillingPortal.mock.invocationCallOrder[0]!, |
| ); |
| }); |
|
|
| it('hands the reserved tab to the portal so it navigates in place', () => { |
| act(reason())(); |
|
|
| expect(openBillingPortal).toHaveBeenCalledTimes(1); |
| expect(openBillingPortal).toHaveBeenCalledWith(reservedTab); |
| }); |
|
|
| it('never sends a paying customer to the upsell page', () => { |
| act(reason())(); |
|
|
| expect(openSpy).not.toHaveBeenCalledWith( |
| expect.stringContaining('/pro'), |
| expect.anything(), |
| expect.anything(), |
| ); |
| expect(reloadSpy).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|
| describe('resolveGateAction β RENEWAL_PENDING', () => { |
| it('reloads to re-pull entitlements instead of opening anything', () => { |
| act(PanelGateReason.RENEWAL_PENDING)(); |
|
|
| expect(reloadSpy).toHaveBeenCalledTimes(1); |
| expect(openSpy).not.toHaveBeenCalled(); |
| expect(openBillingPortal).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|
| describe('resolveGateAction β LAPSED', () => { |
| it('lands on the pricing anchor with the caller-supplied plan preselected', () => { |
| act(PanelGateReason.LAPSED, 'pro_business_yearly')(); |
|
|
| expect(openSpy).toHaveBeenCalledWith( |
| `${PRO_ORIGIN}/pro?wm_reactivate_plan=pro_business_yearly#pricing`, |
| '_blank', |
| 'noopener,noreferrer', |
| ); |
| }); |
|
|
| it('falls back to the live subscription row when the caller supplies no plan', () => { |
| getSubscription.mockReturnValue({ planKey: 'pro_monthly' }); |
|
|
| act(PanelGateReason.LAPSED)(); |
|
|
| expect(openSpy).toHaveBeenCalledWith( |
| `${PRO_ORIGIN}/pro?wm_reactivate_plan=pro_monthly#pricing`, |
| '_blank', |
| 'noopener,noreferrer', |
| ); |
| }); |
|
|
| it('still reaches the pricing anchor when no plan is known at all', () => { |
| |
| |
| act(PanelGateReason.LAPSED, null)(); |
|
|
| expect(openSpy).toHaveBeenCalledWith(`${PRO_ORIGIN}/pro#pricing`, '_blank', 'noopener,noreferrer'); |
| }); |
|
|
| it('percent-encodes a plan key so it cannot break out of the query param', () => { |
| act(PanelGateReason.LAPSED, 'pro&plan=evil#x')(); |
|
|
| expect(openSpy).toHaveBeenCalledWith( |
| `${PRO_ORIGIN}/pro?wm_reactivate_plan=pro%26plan%3Devil%23x#pricing`, |
| '_blank', |
| 'noopener,noreferrer', |
| ); |
| }); |
| }); |
|
|
| describe('resolveGateAction β NONE', () => { |
| it('is a no-op: an ungated surface must not navigate', () => { |
| act(PanelGateReason.NONE)(); |
|
|
| expect(openSpy).not.toHaveBeenCalled(); |
| expect(openAuthModal).not.toHaveBeenCalled(); |
| expect(reloadSpy).not.toHaveBeenCalled(); |
| expect(openBillingPortal).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|