File size: 7,286 Bytes
a20a23c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 | /**
* T3 (#5634) β behavioral coverage for `resolveGateAction`.
*
* `tests/billing-state-wiring.test.mts` asserts this switch by reading the
* SOURCE TEXT of panel-gating.ts (`assert.match(src, /case
* PanelGateReason.PAYMENT_ON_HOLD:/)`). That locks the shape of the code, not
* what a click does β the regex stays green if the branch stops opening
* anything, opens the wrong URL, or loses the popup-blocker pre-reserve.
*
* Here the returned callback is INVOKED against a real (happy-dom) window with
* an injected `openAuthModal`, and the observable effect is asserted:
* which URL opened, in what order, and what got handed to the billing portal.
*
* `prereserveBillingPortalTab` is deliberately NOT mocked β its real body is
* `window.open('', '_blank', 'noopener,noreferrer')`, and that call is the
* behaviour under test. Only `openBillingPortal` is stubbed, because its real
* body fetches a portal session.
*/
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>;
/** Stand-in for the tab the browser hands back from a synchronous window.open. */
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);
});
// Spies are restored by `restoreMocks: true` in vitest.dom.config.mts β a
// second mechanism here would only invite the two to drift. The vi.fn()s above
// are not spies, so they are reset explicitly in beforeEach.
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)();
// Absolute, because the desktop webview has no worldmonitor.app origin β
// a relative href there resolves against tauri://localhost.
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())();
// Synchronous: asserted with no await in between, so a pre-reserve moved
// after the portal fetch (the popup-blocker regression) fails here.
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', () => {
// Both signals absent: the anchor must survive even without the plan
// param β the bare /pro redirect this replaced dropped both.
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();
});
});
|