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();
  });
});