Files
worldmonitor/tests/entitlement-watchdog-parity.test.mts
Elie Habib 38f7002f19 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).
2026-04-24 07:53:51 +04:00

41 lines
1.6 KiB
TypeScript

/**
* 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.',
);
});
});