mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(checkout): entitlement watchdog unblocks Dodo wallet-return deadlock (#3357)
* fix(checkout): entitlement watchdog unblocks Dodo wallet-return deadlock
Buyers completing a Dodo checkout on the subscription-trial flow get
stranded on Dodo's "Payment successful" page indefinitely. HAR evidence
(session cks_0NdL9xlzrFFNivgTeGFU9 / pay_0NdLA3yIfX3BVDoXrFltx, live):
after 3DS succeeds, Dodo's iframe navigates to
/status/{id}/wallet-return?status=succeeded and then emits nothing --
no checkout.status, no checkout.redirect_requested postMessage. Our
onEvent handler never runs, so onSuccess / banner / redirect never
fire. Prior PRs #3298, #3346, #3354 all depended on Dodo emitting a
terminal event; this path emits none.
Fix: merchant-side entitlement watchdog in both the /pro bundle
(pro-test/src/services/checkout.ts) and the dashboard bundle
(src/services/checkout.ts). When the overlay is open, poll
/api/me/entitlement every 3s with a 10min cap. When the webhook flips
the user to pro, close the stuck overlay and run the post-checkout
side effects -- independent of whatever Dodo's iframe does. Existing
event-driven paths are preserved unchanged (they remain the fast path
for non-wallet-return checkouts); the watchdog is the floor.
Idempotency via a successFired closure flag; both the event handler
and the watchdog route through the same runTerminalSuccessSideEffects
function, making double-fires impossible. checkout.closed stops the
watchdog cleanly on cancel.
Observability: Sentry breadcrumb with reason tag on every terminal
success, plus captureMessage at info level when the watchdog resolves
it -- countable signal for prevalence tracking while Dodo investigates.
Rebuilt public/pro/ bundle (index-CiMZEtgt.js to index-QpSvSkuY.js).
Plan: docs/plans/2026-04-23-002-fix-dodo-checkout-entitlement-watchdog-plan.md
Skill: .claude/skills/dodo-wallet-return-skips-postmessage/SKILL.md
* fix(checkout): stop watchdog on destroyCheckoutOverlay to prevent orphan side effects
Greptile P1 on #3357. destroyCheckoutOverlay cleared initialized and
onSuccessCallback but never called _resetOverlaySession, so if the
dashboard layout unmounted mid-checkout the watchdog setInterval kept
running inside the closed-over scope. On entitlement flip, the orphaned
watchdog would fire clearCheckoutAttempt / clearPendingCheckoutIntent /
markPostCheckout / safeCloseOverlay against whatever session was active
by then -- stepping on a new checkout's state or silently closing a
fresh overlay.
Fix: call _resetOverlaySession before dropping references, and null it
out after. _resetOverlaySession is the only accessor for the closure's
stopWatchdog so it must run before the module-scoped slot is cleared.
* test(checkout): extract testable entitlement watchdog + state-machine tests
Greptile residual risk on #3357: the watchdog state machine had no
targeted automated coverage, especially the wallet-return path where
no terminal Dodo event arrives and success is detected only via
entitlement polling.
Extract the watchdog into src/services/entitlement-watchdog.ts as a
pure DI module (fetch / setInterval / clock / token source / onPro
all injected). Mirror the file at pro-test/src/services/entitlement-
watchdog.ts since the two bundles have no cross-root imports (pro-test
alias '@' resolves to pro-test root only). Both src/services/checkout.ts
and pro-test/src/services/checkout.ts now consume createEntitlement-
Watchdog instead of inlining setInterval.
Tests cover the wallet-return scenario explicitly plus the full state
matrix:
- wallet-return path: isPro flips to true -> onPro fires exactly once
- timeout cap: isPro stays false past timeoutMs -> self-terminate
WITHOUT firing onPro
- missing token: tick no-ops, poller keeps trying
- non-2xx response (401/5xx): tick swallows, poller continues
- fetch rejection: tick swallows, poller continues
- idempotence: onPro never fires twice across consecutive pro ticks
- stop(): clears interval immediately, onPro never called
- double-start while active: second start is a no-op
- start after prior onPro: no-op (post-success reuse guard)
Parity test (tests/entitlement-watchdog-parity.test.mts) asserts the
two mirror files are byte-identical so drift alarms at CI time.
Rebuilt public/pro/ bundle (index-QpSvSkuY.js -> index-C-qy2Yt9.js).
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
stripCheckoutIntentFromSearch,
|
||||
buildCheckoutReturnUrl,
|
||||
} from './checkout-intent-url';
|
||||
import { createEntitlementWatchdog, type EntitlementWatchdog } from './entitlement-watchdog';
|
||||
|
||||
let clerk: InstanceType<typeof Clerk> | null = null;
|
||||
let checkoutInFlight = false;
|
||||
@@ -119,9 +120,132 @@ async function _loadClerk(): Promise<InstanceType<typeof Clerk>> {
|
||||
return clerk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entitlement watchdog tuning.
|
||||
*
|
||||
* Why this exists at all: Dodo's overlay can navigate to
|
||||
* `/status/{id}/wallet-return` after a successful payment (observed on
|
||||
* subscription-trial `amount=0` flows) and never emit `checkout.status`
|
||||
* or `checkout.redirect_requested` back to the parent. Prior PRs (#3298
|
||||
* flip to manualRedirect:false, #3346 add redirect_requested handler,
|
||||
* #3354 Escape-key close hatch) all depended on Dodo emitting SOMETHING;
|
||||
* the wallet-return path emits nothing. The watchdog polls our own
|
||||
* entitlement endpoint so the post-checkout journey completes from the
|
||||
* webhook regardless of what Dodo's iframe does.
|
||||
*
|
||||
* INTERVAL: 3000ms floor. Below 2s our own pipeline is eventually
|
||||
* consistent (Convex + Upstash webhook latency) so faster polling just
|
||||
* burns Clerk token refreshes. 3s is imperceptible to humans.
|
||||
*
|
||||
* TIMEOUT: 10 minutes. A real user who paid and left the tab open 10min
|
||||
* without the webhook landing has a different problem (Dodo outage,
|
||||
* webhook pipeline broken) — the fix isn't a longer poll.
|
||||
*/
|
||||
const WATCHDOG_INTERVAL_MS = 3_000;
|
||||
const WATCHDOG_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export function initOverlay(onSuccess?: () => void): void {
|
||||
import('dodopayments-checkout').then(({ DodoPayments }) => {
|
||||
const env = import.meta.env.VITE_DODO_ENVIRONMENT;
|
||||
|
||||
// Closure-scoped watchdog + idempotency state. Reset implicitly
|
||||
// on each new overlay open because `checkout.opened` is what starts
|
||||
// the watchdog and `_terminalFired` only gates within one session:
|
||||
// `checkout.closed` clears both. The SDK Initialize is idempotent
|
||||
// per the main-app comment in src/services/checkout.ts, so this
|
||||
// closure wraps the one-and-only live onEvent handler.
|
||||
let _terminalFired = false;
|
||||
let watchdog: EntitlementWatchdog | null = null;
|
||||
|
||||
const stopWatchdog = (): void => {
|
||||
watchdog?.stop();
|
||||
watchdog = null;
|
||||
};
|
||||
|
||||
const safeCloseOverlay = (): void => {
|
||||
try {
|
||||
if (DodoPayments.Checkout.isOpen?.()) {
|
||||
DodoPayments.Checkout.close();
|
||||
}
|
||||
} catch {
|
||||
// Overlay already gone / SDK mid-teardown.
|
||||
}
|
||||
};
|
||||
|
||||
// Single terminal-success entry point. Both the event handler and
|
||||
// the watchdog route through here so double-fires are impossible.
|
||||
// `redirectTo` optional: the event path supplies Dodo's
|
||||
// redirect_to (which may embed payment_id etc.); the watchdog
|
||||
// path falls back to our canonical success URL.
|
||||
const fireTerminalSuccess = (
|
||||
reason: 'event-status' | 'event-redirect' | 'watchdog',
|
||||
redirectTo?: string,
|
||||
): void => {
|
||||
if (_terminalFired) return;
|
||||
_terminalFired = true;
|
||||
stopWatchdog();
|
||||
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'checkout',
|
||||
message: `terminal success (${reason})`,
|
||||
level: 'info',
|
||||
data: { reason },
|
||||
});
|
||||
|
||||
// Counter-signal so Dodo's wallet-return deadlock prevalence is
|
||||
// measurable in Sentry. We intentionally log `info`, not `error`
|
||||
// — this is expected handling, not a failure. See
|
||||
// `feedback_sentry_level_expected_user_states`.
|
||||
if (reason === 'watchdog') {
|
||||
Sentry.captureMessage('Dodo wallet-return deadlock — watchdog resolved', {
|
||||
level: 'info',
|
||||
tags: { surface: 'pro-marketing', code: 'watchdog_resolved' },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
console.error('[checkout] onSuccess threw:', err);
|
||||
Sentry.captureException(err, {
|
||||
tags: { surface: 'pro-marketing', action: 'on-success' },
|
||||
});
|
||||
}
|
||||
|
||||
// The event-redirect path does its OWN navigation using the
|
||||
// URL Dodo supplied (preserves payment_id / subscription_id
|
||||
// query params downstream consumers may read). Watchdog and
|
||||
// event-status paths use the canonical fallback — Dodo's
|
||||
// status endpoint is authoritative for the entitlement; the
|
||||
// URL params are informational at this point.
|
||||
if (reason === 'event-redirect') {
|
||||
window.location.href = redirectTo || 'https://worldmonitor.app/?wm_checkout=success';
|
||||
} else {
|
||||
safeCloseOverlay();
|
||||
window.location.href = 'https://worldmonitor.app/?wm_checkout=success';
|
||||
}
|
||||
};
|
||||
|
||||
const startWatchdog = (): void => {
|
||||
if (watchdog !== null || _terminalFired) return;
|
||||
watchdog = createEntitlementWatchdog(
|
||||
{
|
||||
endpoint: `${API_BASE}/me/entitlement`,
|
||||
intervalMs: WATCHDOG_INTERVAL_MS,
|
||||
timeoutMs: WATCHDOG_TIMEOUT_MS,
|
||||
},
|
||||
{
|
||||
getToken: getAuthToken,
|
||||
fetch: (input, init) => fetch(input, init),
|
||||
setInterval: (cb, ms) => window.setInterval(cb, ms),
|
||||
clearInterval: (id) => window.clearInterval(id),
|
||||
now: () => Date.now(),
|
||||
onPro: () => fireTerminalSuccess('watchdog'),
|
||||
},
|
||||
);
|
||||
watchdog.start();
|
||||
};
|
||||
|
||||
DodoPayments.Initialize({
|
||||
mode: env === 'live_mode' ? 'live' : 'test',
|
||||
displayType: 'overlay',
|
||||
@@ -141,6 +265,15 @@ export function initOverlay(onSuccess?: () => void): void {
|
||||
console.info('[checkout] dodo event', event.event_type,
|
||||
status !== undefined ? { status } : undefined);
|
||||
|
||||
// `checkout.opened` is the only terminal-adjacent event Dodo
|
||||
// emits reliably on BOTH the happy path and the wallet-return
|
||||
// deadlock path (confirmed via HAR 2026-04-23). It's our
|
||||
// earliest safe moment to arm the watchdog.
|
||||
if (event.event_type === 'checkout.opened') {
|
||||
_terminalFired = false;
|
||||
startWatchdog();
|
||||
}
|
||||
|
||||
// Dodo's documented `manualRedirect: true` flow emits TWO events
|
||||
// on terminal success: `checkout.status` for UI updates, and
|
||||
// `checkout.redirect_requested` carrying the URL WE must navigate
|
||||
@@ -152,7 +285,7 @@ export function initOverlay(onSuccess?: () => void): void {
|
||||
// legacy top-level `event.data.status` read was a guess against
|
||||
// an older SDK version and most likely never matched.
|
||||
if (event.event_type === 'checkout.status' && status === 'succeeded') {
|
||||
onSuccess?.();
|
||||
fireTerminalSuccess('event-status');
|
||||
}
|
||||
if (event.event_type === 'checkout.redirect_requested') {
|
||||
const redirectTo = msg?.redirect_to as string | undefined;
|
||||
@@ -161,7 +294,12 @@ export function initOverlay(onSuccess?: () => void): void {
|
||||
// changelog v1.84.0. Our return_url carries `?wm_checkout=success`
|
||||
// so the dashboard bridge (src/services/checkout-return.ts) fires
|
||||
// regardless of Dodo's appended params.
|
||||
window.location.href = redirectTo || 'https://worldmonitor.app/?wm_checkout=success';
|
||||
fireTerminalSuccess('event-redirect', redirectTo);
|
||||
}
|
||||
if (event.event_type === 'checkout.closed') {
|
||||
// Cancel path. Do not fire success — user didn't pay, or
|
||||
// the watchdog timed out gracefully.
|
||||
stopWatchdog();
|
||||
}
|
||||
if (event.event_type === 'checkout.link_expired') {
|
||||
// Not user-blocking — log-only for now; follow-up if Sentry
|
||||
|
||||
120
pro-test/src/services/entitlement-watchdog.ts
Normal file
120
pro-test/src/services/entitlement-watchdog.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Entitlement-polling watchdog for the Dodo checkout overlay.
|
||||
*
|
||||
* Context: Dodo's overlay can navigate to /status/{id}/wallet-return
|
||||
* after a successful payment (observed on subscription-trial amount=0
|
||||
* flows) and never emit a terminal postMessage (checkout.status /
|
||||
* checkout.redirect_requested) back to the parent. When that happens,
|
||||
* the merchant's onEvent handler can't tell the purchase succeeded and
|
||||
* the user is stranded on Dodo's success page forever.
|
||||
*
|
||||
* This module polls /api/me/entitlement at a fixed interval. When the
|
||||
* webhook has flipped the user to pro, `onPro` fires once. The poller
|
||||
* stops on: (1) a single onPro fire, (2) stop() call, (3) hard timeout.
|
||||
* The caller owns the onPro side effects (post-checkout cleanup,
|
||||
* overlay close, navigation).
|
||||
*
|
||||
* Shape: pure DI module. All environmental dependencies (fetch, timers,
|
||||
* clock, token source) are injected so the state machine is testable
|
||||
* without any DOM or network. See tests/entitlement-watchdog.test.mts.
|
||||
*
|
||||
* This file MUST be kept byte-identical with
|
||||
* pro-test/src/services/entitlement-watchdog.ts. The parity check in
|
||||
* tests/entitlement-watchdog-parity.test.mts enforces that. If you
|
||||
* change one, change both.
|
||||
*/
|
||||
|
||||
export interface EntitlementWatchdogDeps {
|
||||
/** Returns a Bearer token or null if the user isn't signed in yet. */
|
||||
getToken: () => Promise<string | null>;
|
||||
/** Injected fetch — use globalThis.fetch in production, a stub in tests. */
|
||||
fetch: typeof fetch;
|
||||
/** Injected timer. Use window.setInterval in production. */
|
||||
setInterval: (handler: () => void, timeout: number) => number;
|
||||
/** Injected clearer. */
|
||||
clearInterval: (id: number) => void;
|
||||
/** Monotonic-ish clock. Use Date.now in production, a controllable stub in tests. */
|
||||
now: () => number;
|
||||
/** Fired exactly once when entitlement flips to pro. */
|
||||
onPro: () => void;
|
||||
}
|
||||
|
||||
export interface EntitlementWatchdog {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
isActive: () => boolean;
|
||||
}
|
||||
|
||||
export interface EntitlementWatchdogConfig {
|
||||
/** Endpoint to poll. Must return `{ isPro: boolean }` on 200. */
|
||||
endpoint: string;
|
||||
/** Poll interval in ms. 3000ms is our production value. */
|
||||
intervalMs: number;
|
||||
/** Hard cap after which the poller self-terminates WITHOUT firing onPro. */
|
||||
timeoutMs: number;
|
||||
/** AbortSignal timeout for each individual fetch. Optional; defaults to 8000. */
|
||||
fetchTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export function createEntitlementWatchdog(
|
||||
config: EntitlementWatchdogConfig,
|
||||
deps: EntitlementWatchdogDeps,
|
||||
): EntitlementWatchdog {
|
||||
let intervalId: number | null = null;
|
||||
let startedAt = 0;
|
||||
let fired = false;
|
||||
const fetchTimeoutMs = config.fetchTimeoutMs ?? 8_000;
|
||||
|
||||
const stop = (): void => {
|
||||
if (intervalId !== null) {
|
||||
deps.clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const tick = async (): Promise<void> => {
|
||||
// Re-check inside the tick in case start/stop raced with this
|
||||
// scheduled callback. setInterval can fire one more time after
|
||||
// clearInterval in some runtimes.
|
||||
if (intervalId === null || fired) return;
|
||||
if (deps.now() - startedAt > config.timeoutMs) {
|
||||
// Hard cap. Do NOT fire onPro on timeout — if the webhook hasn't
|
||||
// landed after the cap, something else is broken and promoting
|
||||
// the user via a stale entitlement read would mask it.
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = await deps.getToken();
|
||||
if (!token) return;
|
||||
const resp = await deps.fetch(config.endpoint, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(fetchTimeoutMs),
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const body = (await resp.json()) as { isPro?: boolean };
|
||||
if (body.isPro && !fired) {
|
||||
fired = true;
|
||||
stop();
|
||||
deps.onPro();
|
||||
}
|
||||
} catch {
|
||||
// Swallow — poll retries on next tick. Unexpected exceptions
|
||||
// would otherwise spam Sentry once every interval for up to
|
||||
// timeoutMs.
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start: (): void => {
|
||||
if (intervalId !== null || fired) return;
|
||||
startedAt = deps.now();
|
||||
// Cast: the DOM lib types setInterval's return as number, the
|
||||
// Node lib types it as NodeJS.Timeout. Injected deps use number
|
||||
// because that matches window.setInterval and our fake timer.
|
||||
intervalId = deps.setInterval(() => { void tick(); }, config.intervalMs);
|
||||
},
|
||||
stop,
|
||||
isActive: (): boolean => intervalId !== null,
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -144,7 +144,7 @@
|
||||
}
|
||||
</script>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
|
||||
<script type="module" crossorigin src="/pro/assets/index-CiMZEtgt.js"></script>
|
||||
<script type="module" crossorigin src="/pro/assets/index-C-qy2Yt9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/pro/assets/index-xSEP0-ib.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { loadActiveReferral } from './referral-capture';
|
||||
import { showDuplicateSubscriptionDialog } from './checkout-duplicate-dialog';
|
||||
import { resolvePlanDisplayName } from './checkout-plan-names';
|
||||
import { createEntitlementWatchdog, type EntitlementWatchdog } from './entitlement-watchdog';
|
||||
|
||||
export {
|
||||
EXTENDED_UNLOCK_TIMEOUT_MS,
|
||||
@@ -133,6 +134,20 @@ let _resetOverlaySession: (() => void) | null = null;
|
||||
let _watchersInitialized = false;
|
||||
let _escapeHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Entitlement watchdog tuning (mirrors pro-test/src/services/checkout.ts).
|
||||
*
|
||||
* Dodo's overlay can navigate to `/status/{id}/wallet-return` after a
|
||||
* successful payment (observed on subscription-trial `amount=0` flows)
|
||||
* and never emit `checkout.status` or `checkout.redirect_requested`.
|
||||
* Prior PRs assumed Dodo would emit SOMETHING; the wallet-return path
|
||||
* emits nothing. Watchdog polls our own entitlement endpoint so the
|
||||
* post-checkout cleanup runs from the webhook regardless of what
|
||||
* Dodo's iframe does. See docs/plans/2026-04-23-002-*-plan.md.
|
||||
*/
|
||||
const WATCHDOG_INTERVAL_MS = 3_000;
|
||||
const WATCHDOG_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Dodo's hosted overlay has been observed to deadlock: the in-iframe X
|
||||
* button hits `GET /api/checkout/sessions/{id}/payment-link` → 404 →
|
||||
@@ -178,48 +193,130 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
|
||||
// must reset when a new overlay opens. `openCheckout` resets this
|
||||
// flag via the exported `resetOverlaySessionState()` helper below.
|
||||
let successFired = false;
|
||||
_resetOverlaySession = () => { successFired = false; };
|
||||
let navigationFired = false;
|
||||
let watchdog: EntitlementWatchdog | null = null;
|
||||
|
||||
const stopWatchdog = (): void => {
|
||||
watchdog?.stop();
|
||||
watchdog = null;
|
||||
};
|
||||
|
||||
_resetOverlaySession = () => {
|
||||
successFired = false;
|
||||
navigationFired = false;
|
||||
stopWatchdog();
|
||||
};
|
||||
|
||||
// Shared terminal-success side effects (run ONCE per overlay session).
|
||||
// Called from: `checkout.status=succeeded` (event path), the
|
||||
// watchdog when entitlement flips to pro (fallback path), and the
|
||||
// watchdog-free `checkout.redirect_requested` handler when it arrives
|
||||
// before status (rare but possible per docs). The `successFired` flag
|
||||
// makes subsequent callers no-op, preserving prior single-fire semantics.
|
||||
//
|
||||
// The entitlement watcher in panel-layout.ts owns the free→pro reload
|
||||
// (REQUIRES_SKIP_INITIAL_SNAPSHOT_BEHAVIOR; see mirror marker in
|
||||
// panel-layout.ts) — this block does NOT reload or navigate on its own.
|
||||
const runTerminalSuccessSideEffects = (reason: 'event-status' | 'event-redirect' | 'watchdog'): void => {
|
||||
if (successFired) return;
|
||||
successFired = true;
|
||||
stopWatchdog();
|
||||
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'checkout',
|
||||
message: `terminal success (${reason})`,
|
||||
level: 'info',
|
||||
data: { reason },
|
||||
});
|
||||
if (reason === 'watchdog') {
|
||||
// Counter-signal so Dodo's wallet-return deadlock prevalence is
|
||||
// measurable in Sentry. `info` level, not `error`, per
|
||||
// feedback_sentry_level_expected_user_states.
|
||||
Sentry.captureMessage('Dodo wallet-return deadlock — watchdog resolved', {
|
||||
level: 'info',
|
||||
tags: { component: 'dodo-checkout', code: 'watchdog_resolved' },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
onSuccessCallback?.();
|
||||
} catch (err) {
|
||||
console.error('[checkout] onSuccessCallback threw:', err);
|
||||
Sentry.captureException(err, {
|
||||
tags: { component: 'dodo-checkout', action: 'on-success' },
|
||||
});
|
||||
}
|
||||
// Terminal success: clear both keys. LAST_CHECKOUT_ATTEMPT_KEY
|
||||
// is no longer needed (no retry context required); PENDING is
|
||||
// cleared to avoid auto-opening the overlay on the reload.
|
||||
clearCheckoutAttempt('success');
|
||||
clearPendingCheckoutIntent();
|
||||
// Session flag so the reloaded page seeds the entitlement transition
|
||||
// detector as post-checkout — see comment block preserved from the
|
||||
// original inlined handler below for the full rationale.
|
||||
markPostCheckout();
|
||||
};
|
||||
|
||||
const startWatchdog = (): void => {
|
||||
if (watchdog !== null || successFired) return;
|
||||
watchdog = createEntitlementWatchdog(
|
||||
{
|
||||
endpoint: '/api/me/entitlement',
|
||||
intervalMs: WATCHDOG_INTERVAL_MS,
|
||||
timeoutMs: WATCHDOG_TIMEOUT_MS,
|
||||
},
|
||||
{
|
||||
getToken: getClerkToken,
|
||||
fetch: (input, init) => fetch(input, init),
|
||||
setInterval: (cb, ms) => window.setInterval(cb, ms),
|
||||
clearInterval: (id) => window.clearInterval(id),
|
||||
now: () => Date.now(),
|
||||
onPro: () => {
|
||||
runTerminalSuccessSideEffects('watchdog');
|
||||
// Close the stuck overlay so the entitlement watcher's reload
|
||||
// is not hidden behind Dodo's "payment successful" page.
|
||||
safeCloseOverlay();
|
||||
},
|
||||
},
|
||||
);
|
||||
watchdog.start();
|
||||
};
|
||||
|
||||
DodoPayments.Initialize({
|
||||
mode: env === 'live_mode' ? 'live' : 'test',
|
||||
displayType: 'overlay',
|
||||
onEvent: (event: CheckoutEvent) => {
|
||||
switch (event.event_type) {
|
||||
case 'checkout.opened':
|
||||
// Arm the watchdog at the earliest safe moment. HAR 2026-04-23
|
||||
// confirms `checkout.opened` fires on both the happy path AND
|
||||
// the wallet-return deadlock path; terminal events do not.
|
||||
startWatchdog();
|
||||
break;
|
||||
case 'checkout.status': {
|
||||
// Docs-documented shape is ONLY `event.data.message.status` —
|
||||
// the prior top-level `event.data.status` read was a guess
|
||||
// against an older SDK version and most likely never matched.
|
||||
// (overlay-checkout.mdx / inline-checkout.mdx, SDK >= 0.109.2).
|
||||
//
|
||||
// Reload ownership: the entitlement watcher in panel-layout.ts
|
||||
// is the SINGLE reload source (fires on free→pro transition).
|
||||
// We no longer schedule a belt-and-braces setTimeout reload
|
||||
// here — that competed with the watcher and made "still
|
||||
// unlocking" UX impossible because the banner was guaranteed
|
||||
// to be wiped at 3s regardless of webhook latency.
|
||||
//
|
||||
// REQUIRES_SKIP_INITIAL_SNAPSHOT_BEHAVIOR — the watcher's
|
||||
// first-snapshot seeding depends on PR #3163 (merged
|
||||
// 2026-04-18) having fixed the swallow-first-snapshot bug.
|
||||
// If that PR is ever reverted or its behavior regresses,
|
||||
// tests in tests/entitlement-transition.test.mts will fail
|
||||
// (specifically "simulates the incident sequence" case); see
|
||||
// the mirror marker in panel-layout.ts.
|
||||
const rawData = event.data as Record<string, unknown> | undefined;
|
||||
const status = (rawData?.message as Record<string, unknown> | undefined)?.status;
|
||||
if (status === 'succeeded') {
|
||||
successFired = true;
|
||||
onSuccessCallback?.();
|
||||
// Terminal success: clear both keys. LAST_CHECKOUT_ATTEMPT_KEY
|
||||
// is no longer needed (no retry context required); PENDING is
|
||||
// cleared to avoid auto-opening the overlay on the reload.
|
||||
clearCheckoutAttempt('success');
|
||||
clearPendingCheckoutIntent();
|
||||
// Mark a session flag so the reloaded page seeds the entitlement
|
||||
// transition detector as post-checkout — without this, the
|
||||
// detector would treat the first pro snapshot as "legacy-pro
|
||||
// baseline" and swallow the activation.
|
||||
//
|
||||
// Reload ownership: the entitlement watcher in panel-layout.ts
|
||||
// is the SINGLE reload source (fires on free→pro transition).
|
||||
// We no longer schedule a belt-and-braces setTimeout reload
|
||||
// here — that competed with the watcher and made "still
|
||||
// unlocking" UX impossible because the banner was guaranteed
|
||||
// to be wiped at 3s regardless of webhook latency.
|
||||
//
|
||||
// REQUIRES_SKIP_INITIAL_SNAPSHOT_BEHAVIOR — the watcher's
|
||||
// first-snapshot seeding depends on PR #3163 (merged
|
||||
// 2026-04-18) having fixed the swallow-first-snapshot bug.
|
||||
// If that PR is ever reverted or its behavior regresses,
|
||||
// tests in tests/entitlement-transition.test.mts will fail
|
||||
// (specifically "simulates the incident sequence" case); see
|
||||
// the mirror marker in panel-layout.ts.
|
||||
markPostCheckout();
|
||||
runTerminalSuccessSideEffects('event-status');
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -231,6 +328,7 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
|
||||
// the retry CTA. The attempt record will be cleared later by
|
||||
// the terminal path that actually resolves (success, dismissed,
|
||||
// duplicate, or the mount-time abandonment sweep).
|
||||
stopWatchdog();
|
||||
if (!successFired) {
|
||||
clearPendingCheckoutIntent();
|
||||
}
|
||||
@@ -242,8 +340,17 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
|
||||
// fail on Safari with an orphaned about:blank tab; we follow
|
||||
// the docs-prescribed handler instead.
|
||||
// (overlay-checkout.mdx: "Redirect the customer manually".)
|
||||
//
|
||||
// On the happy path both `checkout.status=succeeded` and
|
||||
// `checkout.redirect_requested` fire — status runs the
|
||||
// markPostCheckout + cleanup side effects, redirect navigates
|
||||
// away. When only redirect_requested fires (no prior status),
|
||||
// we run the side effects here so the post-checkout flag is
|
||||
// set before we navigate.
|
||||
const redirectTo = (event.data?.message as Record<string, unknown> | undefined)?.redirect_to as string | undefined;
|
||||
if (redirectTo) {
|
||||
if (!successFired) runTerminalSuccessSideEffects('event-redirect');
|
||||
if (redirectTo && !navigationFired) {
|
||||
navigationFired = true;
|
||||
window.location.href = redirectTo;
|
||||
}
|
||||
break;
|
||||
@@ -255,6 +362,7 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
|
||||
// deadlock bug (payment-link 404 + render loop) never reaches
|
||||
// this branch — it traps inside their iframe — but any error
|
||||
// that DOES escape should not leave a broken overlay mounted.
|
||||
stopWatchdog();
|
||||
safeCloseOverlay();
|
||||
break;
|
||||
}
|
||||
@@ -276,6 +384,15 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
|
||||
* stored success callback so a new layout can register its own callback.
|
||||
*/
|
||||
export function destroyCheckoutOverlay(): void {
|
||||
// Stop any in-flight watchdog BEFORE we drop references. If the layout
|
||||
// unmounts mid-checkout, the watchdog's setInterval would otherwise
|
||||
// keep running inside the closed-over scope and, on entitlement flip,
|
||||
// fire side effects (clearCheckoutAttempt, clearPendingCheckoutIntent,
|
||||
// markPostCheckout, safeCloseOverlay) against a subsequent session's
|
||||
// state. _resetOverlaySession is the only accessor for that closure's
|
||||
// stopWatchdog.
|
||||
_resetOverlaySession?.();
|
||||
_resetOverlaySession = null;
|
||||
initialized = false;
|
||||
onSuccessCallback = null;
|
||||
if (_escapeHandler) {
|
||||
|
||||
120
src/services/entitlement-watchdog.ts
Normal file
120
src/services/entitlement-watchdog.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Entitlement-polling watchdog for the Dodo checkout overlay.
|
||||
*
|
||||
* Context: Dodo's overlay can navigate to /status/{id}/wallet-return
|
||||
* after a successful payment (observed on subscription-trial amount=0
|
||||
* flows) and never emit a terminal postMessage (checkout.status /
|
||||
* checkout.redirect_requested) back to the parent. When that happens,
|
||||
* the merchant's onEvent handler can't tell the purchase succeeded and
|
||||
* the user is stranded on Dodo's success page forever.
|
||||
*
|
||||
* This module polls /api/me/entitlement at a fixed interval. When the
|
||||
* webhook has flipped the user to pro, `onPro` fires once. The poller
|
||||
* stops on: (1) a single onPro fire, (2) stop() call, (3) hard timeout.
|
||||
* The caller owns the onPro side effects (post-checkout cleanup,
|
||||
* overlay close, navigation).
|
||||
*
|
||||
* Shape: pure DI module. All environmental dependencies (fetch, timers,
|
||||
* clock, token source) are injected so the state machine is testable
|
||||
* without any DOM or network. See tests/entitlement-watchdog.test.mts.
|
||||
*
|
||||
* This file MUST be kept byte-identical with
|
||||
* pro-test/src/services/entitlement-watchdog.ts. The parity check in
|
||||
* tests/entitlement-watchdog-parity.test.mts enforces that. If you
|
||||
* change one, change both.
|
||||
*/
|
||||
|
||||
export interface EntitlementWatchdogDeps {
|
||||
/** Returns a Bearer token or null if the user isn't signed in yet. */
|
||||
getToken: () => Promise<string | null>;
|
||||
/** Injected fetch — use globalThis.fetch in production, a stub in tests. */
|
||||
fetch: typeof fetch;
|
||||
/** Injected timer. Use window.setInterval in production. */
|
||||
setInterval: (handler: () => void, timeout: number) => number;
|
||||
/** Injected clearer. */
|
||||
clearInterval: (id: number) => void;
|
||||
/** Monotonic-ish clock. Use Date.now in production, a controllable stub in tests. */
|
||||
now: () => number;
|
||||
/** Fired exactly once when entitlement flips to pro. */
|
||||
onPro: () => void;
|
||||
}
|
||||
|
||||
export interface EntitlementWatchdog {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
isActive: () => boolean;
|
||||
}
|
||||
|
||||
export interface EntitlementWatchdogConfig {
|
||||
/** Endpoint to poll. Must return `{ isPro: boolean }` on 200. */
|
||||
endpoint: string;
|
||||
/** Poll interval in ms. 3000ms is our production value. */
|
||||
intervalMs: number;
|
||||
/** Hard cap after which the poller self-terminates WITHOUT firing onPro. */
|
||||
timeoutMs: number;
|
||||
/** AbortSignal timeout for each individual fetch. Optional; defaults to 8000. */
|
||||
fetchTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export function createEntitlementWatchdog(
|
||||
config: EntitlementWatchdogConfig,
|
||||
deps: EntitlementWatchdogDeps,
|
||||
): EntitlementWatchdog {
|
||||
let intervalId: number | null = null;
|
||||
let startedAt = 0;
|
||||
let fired = false;
|
||||
const fetchTimeoutMs = config.fetchTimeoutMs ?? 8_000;
|
||||
|
||||
const stop = (): void => {
|
||||
if (intervalId !== null) {
|
||||
deps.clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const tick = async (): Promise<void> => {
|
||||
// Re-check inside the tick in case start/stop raced with this
|
||||
// scheduled callback. setInterval can fire one more time after
|
||||
// clearInterval in some runtimes.
|
||||
if (intervalId === null || fired) return;
|
||||
if (deps.now() - startedAt > config.timeoutMs) {
|
||||
// Hard cap. Do NOT fire onPro on timeout — if the webhook hasn't
|
||||
// landed after the cap, something else is broken and promoting
|
||||
// the user via a stale entitlement read would mask it.
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = await deps.getToken();
|
||||
if (!token) return;
|
||||
const resp = await deps.fetch(config.endpoint, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(fetchTimeoutMs),
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const body = (await resp.json()) as { isPro?: boolean };
|
||||
if (body.isPro && !fired) {
|
||||
fired = true;
|
||||
stop();
|
||||
deps.onPro();
|
||||
}
|
||||
} catch {
|
||||
// Swallow — poll retries on next tick. Unexpected exceptions
|
||||
// would otherwise spam Sentry once every interval for up to
|
||||
// timeoutMs.
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start: (): void => {
|
||||
if (intervalId !== null || fired) return;
|
||||
startedAt = deps.now();
|
||||
// Cast: the DOM lib types setInterval's return as number, the
|
||||
// Node lib types it as NodeJS.Timeout. Injected deps use number
|
||||
// because that matches window.setInterval and our fake timer.
|
||||
intervalId = deps.setInterval(() => { void tick(); }, config.intervalMs);
|
||||
},
|
||||
stop,
|
||||
isActive: (): boolean => intervalId !== null,
|
||||
};
|
||||
}
|
||||
40
tests/entitlement-watchdog-parity.test.mts
Normal file
40
tests/entitlement-watchdog-parity.test.mts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Parity check for the entitlement-watchdog mirror files.
|
||||
*
|
||||
* `src/services/entitlement-watchdog.ts` (dashboard bundle) and
|
||||
* `pro-test/src/services/entitlement-watchdog.ts` (marketing bundle)
|
||||
* MUST be byte-identical. The dashboard version is what the unit tests
|
||||
* in entitlement-watchdog.test.mts cover; pro-test imports its own copy
|
||||
* because the bundles have no cross-root imports (Vite alias `@`
|
||||
* resolves to the pro-test root only). A silent drift between the two
|
||||
* copies would leave /pro's watchdog uncovered and possibly broken.
|
||||
*
|
||||
* Prior-art: the scripts/shared/ mirror convention
|
||||
* (feedback_shared_dir_mirror_requirement).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('entitlement-watchdog.ts mirror parity', () => {
|
||||
it('src/services/entitlement-watchdog.ts and pro-test/src/services/entitlement-watchdog.ts are byte-identical', async () => {
|
||||
const dashboard = await readFile(
|
||||
resolve(__dirname, '..', 'src/services/entitlement-watchdog.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
const marketing = await readFile(
|
||||
resolve(__dirname, '..', 'pro-test/src/services/entitlement-watchdog.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
assert.equal(
|
||||
dashboard,
|
||||
marketing,
|
||||
'If this fails, cp src/services/entitlement-watchdog.ts pro-test/src/services/entitlement-watchdog.ts (or the reverse) and re-run the gates. The two files MUST stay in lockstep.',
|
||||
);
|
||||
});
|
||||
});
|
||||
244
tests/entitlement-watchdog.test.mts
Normal file
244
tests/entitlement-watchdog.test.mts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* State-machine tests for createEntitlementWatchdog.
|
||||
*
|
||||
* This module is the fallback that unblocks the Dodo wallet-return
|
||||
* deadlock: when Dodo's overlay completes a payment but never emits
|
||||
* checkout.status / checkout.redirect_requested, the watchdog polls
|
||||
* /api/me/entitlement until the webhook flips isPro, then fires onPro.
|
||||
* See PR #3357 / skill dodo-wallet-return-skips-postmessage.
|
||||
*
|
||||
* Scenarios covered:
|
||||
* - Wallet-return path: N isPro:false polls then isPro:true -> onPro
|
||||
* fires exactly once.
|
||||
* - Timeout cap: isPro never flips -> poller self-terminates without
|
||||
* firing onPro (we'd rather strand than falsely promote).
|
||||
* - Missing token: tick is a no-op; poller keeps trying.
|
||||
* - Non-200 responses (401 / 404 / 5xx): tick swallows, poller keeps
|
||||
* trying.
|
||||
* - Fetch rejects (offline / abort): tick swallows, poller keeps
|
||||
* trying.
|
||||
* - Idempotence: once onPro has fired, later ticks do not re-fire.
|
||||
* - stop(): clears the interval immediately, onPro never called.
|
||||
* - Double-start: second start() while active is a no-op.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
createEntitlementWatchdog,
|
||||
type EntitlementWatchdogDeps,
|
||||
} from '@/services/entitlement-watchdog';
|
||||
|
||||
interface Harness {
|
||||
deps: EntitlementWatchdogDeps;
|
||||
/** Drive N ticks synchronously, resolving between each. */
|
||||
tickTimes: (n: number) => Promise<void>;
|
||||
/** Advance the fake clock by ms without firing ticks. */
|
||||
advanceTime: (ms: number) => void;
|
||||
fetchCalls: number;
|
||||
tokenCalls: number;
|
||||
onProCalls: number;
|
||||
/** Active interval callback, or null if stopped. */
|
||||
getActiveCb: () => (() => void) | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a harness whose fetch response is driven by `respond`, called
|
||||
* for each tick. Returning null from `respond` means "reject" (simulates
|
||||
* network error / AbortError). Returning a Response-shaped object drives
|
||||
* the ok / json branches.
|
||||
*/
|
||||
function buildHarness(
|
||||
respond: (tickIndex: number) => { ok: boolean; body?: unknown } | null,
|
||||
tokenProvider: (tickIndex: number) => string | null = () => 'tok_test',
|
||||
): Harness {
|
||||
let activeId: number | null = null;
|
||||
let activeCb: (() => void) | null = null;
|
||||
let nextId = 1;
|
||||
let fakeNow = 1_000;
|
||||
let tickIndex = 0;
|
||||
const harness: Harness = {
|
||||
deps: {} as EntitlementWatchdogDeps,
|
||||
tickTimes: async (n: number): Promise<void> => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (!activeCb) return;
|
||||
activeCb();
|
||||
// Drain microtasks + the tick's async chain (await getToken,
|
||||
// await fetch, await resp.json). 3 macro yields is enough at
|
||||
// this depth; node:test runs synchronously otherwise.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
},
|
||||
advanceTime: (ms: number): void => { fakeNow += ms; },
|
||||
fetchCalls: 0,
|
||||
tokenCalls: 0,
|
||||
onProCalls: 0,
|
||||
getActiveCb: () => activeCb,
|
||||
};
|
||||
harness.deps = {
|
||||
getToken: async () => {
|
||||
const t = tokenProvider(harness.tokenCalls);
|
||||
harness.tokenCalls++;
|
||||
return t;
|
||||
},
|
||||
fetch: async (_input: unknown, _init?: unknown) => {
|
||||
const idx = harness.fetchCalls;
|
||||
harness.fetchCalls++;
|
||||
const r = respond(idx);
|
||||
if (r === null) {
|
||||
throw new Error('simulated fetch error');
|
||||
}
|
||||
return {
|
||||
ok: r.ok,
|
||||
status: r.ok ? 200 : 500,
|
||||
json: async () => r.body ?? {},
|
||||
} as unknown as Response;
|
||||
},
|
||||
setInterval: ((cb: () => void, _ms: number) => {
|
||||
activeCb = cb;
|
||||
activeId = nextId++;
|
||||
return activeId;
|
||||
}) as typeof setInterval,
|
||||
clearInterval: ((id: number) => {
|
||||
if (id === activeId) {
|
||||
activeCb = null;
|
||||
activeId = null;
|
||||
}
|
||||
}) as typeof clearInterval,
|
||||
now: () => fakeNow,
|
||||
onPro: () => {
|
||||
harness.onProCalls++;
|
||||
},
|
||||
};
|
||||
// Also register tickIndex for future extension
|
||||
void tickIndex;
|
||||
return harness;
|
||||
}
|
||||
|
||||
describe('createEntitlementWatchdog', () => {
|
||||
it('wallet-return path: fires onPro exactly once after isPro flips to true', async () => {
|
||||
const h = buildHarness((i) => ({ ok: true, body: { isPro: i >= 3 } }));
|
||||
const wd = createEntitlementWatchdog(
|
||||
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
|
||||
h.deps,
|
||||
);
|
||||
wd.start();
|
||||
// Three non-pro ticks, then pro tick -> onPro.
|
||||
await h.tickTimes(5);
|
||||
assert.equal(h.onProCalls, 1, 'onPro should fire exactly once');
|
||||
assert.equal(wd.isActive(), false, 'watchdog should stop after success');
|
||||
assert.equal(h.getActiveCb(), null, 'interval should be cleared');
|
||||
});
|
||||
|
||||
it('timeout cap: never fires onPro if isPro stays false past timeoutMs', async () => {
|
||||
const h = buildHarness(() => ({ ok: true, body: { isPro: false } }));
|
||||
const wd = createEntitlementWatchdog(
|
||||
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 10_000 },
|
||||
h.deps,
|
||||
);
|
||||
wd.start();
|
||||
await h.tickTimes(2);
|
||||
assert.equal(h.onProCalls, 0);
|
||||
assert.equal(wd.isActive(), true);
|
||||
// Push the clock past the timeoutMs cap and tick once more.
|
||||
h.advanceTime(11_000);
|
||||
await h.tickTimes(1);
|
||||
assert.equal(h.onProCalls, 0, 'onPro must NOT fire on timeout');
|
||||
assert.equal(wd.isActive(), false, 'watchdog self-terminates on timeout');
|
||||
});
|
||||
|
||||
it('missing token: tick is a no-op, poller keeps running', async () => {
|
||||
const h = buildHarness(
|
||||
() => ({ ok: true, body: { isPro: true } }),
|
||||
(i) => (i < 2 ? null : 'tok_test'),
|
||||
);
|
||||
const wd = createEntitlementWatchdog(
|
||||
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
|
||||
h.deps,
|
||||
);
|
||||
wd.start();
|
||||
await h.tickTimes(3);
|
||||
assert.equal(h.fetchCalls, 1, 'fetch only runs when token is present');
|
||||
assert.equal(h.onProCalls, 1, 'onPro fires on the tick that got a token');
|
||||
});
|
||||
|
||||
it('non-2xx response: tick swallows, poller continues', async () => {
|
||||
// First two ticks return 401, third returns isPro:true.
|
||||
const h = buildHarness((i) => (i < 2 ? { ok: false } : { ok: true, body: { isPro: true } }));
|
||||
const wd = createEntitlementWatchdog(
|
||||
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
|
||||
h.deps,
|
||||
);
|
||||
wd.start();
|
||||
await h.tickTimes(3);
|
||||
assert.equal(h.onProCalls, 1);
|
||||
assert.equal(wd.isActive(), false);
|
||||
});
|
||||
|
||||
it('fetch rejection: tick swallows, poller continues', async () => {
|
||||
// First tick rejects, second succeeds with isPro:true.
|
||||
const h = buildHarness((i) => (i === 0 ? null : { ok: true, body: { isPro: true } }));
|
||||
const wd = createEntitlementWatchdog(
|
||||
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
|
||||
h.deps,
|
||||
);
|
||||
wd.start();
|
||||
await h.tickTimes(2);
|
||||
assert.equal(h.onProCalls, 1, 'poller survives a rejection and fires on next success');
|
||||
});
|
||||
|
||||
it('idempotence: onPro does not fire twice if isPro stays true across ticks', async () => {
|
||||
const h = buildHarness(() => ({ ok: true, body: { isPro: true } }));
|
||||
const wd = createEntitlementWatchdog(
|
||||
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
|
||||
h.deps,
|
||||
);
|
||||
wd.start();
|
||||
await h.tickTimes(5);
|
||||
assert.equal(h.onProCalls, 1, 'onPro only fires once even if stop races with later ticks');
|
||||
});
|
||||
|
||||
it('stop(): clears interval immediately; onPro never fires', async () => {
|
||||
const h = buildHarness(() => ({ ok: true, body: { isPro: true } }));
|
||||
const wd = createEntitlementWatchdog(
|
||||
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
|
||||
h.deps,
|
||||
);
|
||||
wd.start();
|
||||
wd.stop();
|
||||
await h.tickTimes(3);
|
||||
assert.equal(h.onProCalls, 0);
|
||||
assert.equal(wd.isActive(), false);
|
||||
assert.equal(h.fetchCalls, 0, 'no fetches should happen after stop()');
|
||||
});
|
||||
|
||||
it('double-start is a no-op while active', async () => {
|
||||
const h = buildHarness(() => ({ ok: true, body: { isPro: false } }));
|
||||
const wd = createEntitlementWatchdog(
|
||||
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
|
||||
h.deps,
|
||||
);
|
||||
wd.start();
|
||||
const cbBefore = h.getActiveCb();
|
||||
wd.start();
|
||||
const cbAfter = h.getActiveCb();
|
||||
assert.strictEqual(cbBefore, cbAfter, 'second start must not register a new interval');
|
||||
});
|
||||
|
||||
it('start after onPro fired is a no-op (post-success reuse guard)', async () => {
|
||||
const h = buildHarness(() => ({ ok: true, body: { isPro: true } }));
|
||||
const wd = createEntitlementWatchdog(
|
||||
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
|
||||
h.deps,
|
||||
);
|
||||
wd.start();
|
||||
await h.tickTimes(1);
|
||||
assert.equal(h.onProCalls, 1);
|
||||
// Pretend caller mistakenly re-starts the same instance.
|
||||
wd.start();
|
||||
await h.tickTimes(2);
|
||||
assert.equal(h.onProCalls, 1, 'onPro must not re-fire after a prior success on the same instance');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user