// ── WalletShopSpec.js ──────────────────────────────────────────────────────── // // Unit specs for wallet-shop.js — the BUY-ITEM click handler that drives // the shop applet's purchase flow (Chunk 4 of the wallet-expansion sprint). // // DOM contract assumed by `initWalletShop()`: // // Each shop tile carries `data-shop-item-slug`, `data-price-cents`, and // `data-item-name`. Inside the tile, a `.tt-buy-btn` element is the // click target. Disabled tiles have `.btn-disabled` on the BUY btn + // should NOT trigger the buy flow. // // `window.showGuard(anchor, message, onConfirm)` is the project's // guard-portal API (from base.html). We spy on it to assert message // + callback wiring. // // `window.Stripe` and `window.fetch` are stubbed out per-test so we // never hit the real Stripe.js or backend. // // API under test: // `initWalletShop()` — attaches click listeners to every `.tt-buy-btn` // inside `.wallet-shop`. Idempotent (calling twice // doesn't double-fire). // ───────────────────────────────────────────────────────────────────────────── function _seedShopFixture() { const root = document.createElement('section'); root.className = 'wallet-shop'; root.id = 'id_wallet_shop'; // `_doBuy` reads these from `.wallet-shop` dataset; without them the // buy flow short-circuits at the no-PM alert before reaching fetch. root.dataset.defaultPaymentMethodId = 'pm_test_4242'; root.dataset.stripePublishableKey = 'pk_test_fixture'; root.innerHTML = `

Tithe Token

1 Tithe + 144 Writs

$1

Wristband

$20

Already owned
`; document.body.appendChild(root); return root; } describe('WalletShop.initWalletShop', () => { let fixture; let _origFetch; let _origStripe; let _origShowGuard; let _origWalletTooltips; beforeEach(() => { fixture = _seedShopFixture(); // Save+stub instead of spyOn — another spec elsewhere may already // have a fetch spy active, and Jasmine refuses to double-spy on // the same method ("fetch has already been spied upon"). Manual // save+restore is double-spy-safe + scoped per spec block. _origFetch = window.fetch; window.fetch = jasmine.createSpy('fetch').and.returnValue( Promise.resolve({ ok: true, json: () => Promise.resolve({}) }) ); _origStripe = window.Stripe; window.Stripe = jasmine.createSpy('Stripe').and.returnValue({ confirmCardPayment: jasmine.createSpy('confirmCardPayment') .and.returnValue(Promise.resolve({ paymentIntent: { status: 'succeeded' } })), }); _origShowGuard = window.showGuard; window.showGuard = jasmine.createSpy('showGuard'); // Stub WalletTooltips.pin/unpin so we can assert the buy flow // pins the tooltip on guard-open + unpins on confirm/dismiss. _origWalletTooltips = window.WalletTooltips; window.WalletTooltips = { pin: jasmine.createSpy('pin'), unpin: jasmine.createSpy('unpin'), }; }); afterEach(() => { fixture.remove(); window.fetch = _origFetch; window.Stripe = _origStripe; window.showGuard = _origShowGuard; window.WalletTooltips = _origWalletTooltips; }); // ── T1 ── click on enabled BUY opens guard portal w. price prompt ─────── it('T1: clicking BUY ITEM on tithe-1 opens guard with the item name + price', () => { WalletShop.initWalletShop(); const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn'); btn.click(); expect(window.showGuard).toHaveBeenCalled(); const args = window.showGuard.calls.mostRecent().args; expect(args[0]).toBe(btn); // anchor expect(args[1]).toContain('Tithe Token'); expect(args[1]).toContain('$1'); expect(typeof args[2]).toBe('function'); // onConfirm callback }); // ── T2 ── capped items render no BUY btn at all (status-only microtext) it('T2: capped band-1 tile renders no BUY btn (only the "Already owned" pill)', () => { WalletShop.initWalletShop(); // No `.tt-buy-btn` inside the band tile — capped items follow the // Game Kit "Equipped" / "In-Use: X" pattern: status text only, no // disabled action btn. Clicking anywhere on the tile is a no-op. const btns = fixture.querySelectorAll('#id_shop_band-1 .tt-buy-btn'); expect(btns.length).toBe(0); // The microtext is rendered (cloned into the mini portal on hover). const ownedText = fixture.querySelector('#id_shop_band-1 .tt-already-owned'); expect(ownedText).not.toBeNull(); expect(ownedText.textContent.trim()).toBe('Already owned'); }); // ── T3 ── onConfirm callback POSTs to /shop/buy w. the right slug ─────── it('T3: invoking the onConfirm callback POSTs shop_item_slug to /shop/buy', () => { // T3-local fetch override: never-resolving promise so the async // chain pauses at the FIRST await + doesn't progress into the // Stripe / confirm-endpoint dance that gets torn down by // afterEach. Without this, the unawaited continuation fires // `window.Stripe(pubKey)` after afterEach has restored // window.Stripe = undefined → unhandled-promise-rejection error. window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {})); WalletShop.initWalletShop(); const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn'); btn.click(); const onConfirm = window.showGuard.calls.mostRecent().args[2]; onConfirm(); expect(window.fetch).toHaveBeenCalled(); const [url, opts] = window.fetch.calls.mostRecent().args; expect(url).toContain('/dashboard/wallet/shop/buy'); expect(opts.method).toBe('POST'); expect(opts.body).toContain('shop_item_slug=tithe-1'); }); // ── T4 ── initWalletShop is idempotent (calling twice doesn't double-wire) ─ it('T4: calling initWalletShop twice does not double-fire the guard portal', () => { WalletShop.initWalletShop(); WalletShop.initWalletShop(); const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn'); btn.click(); expect(window.showGuard.calls.count()).toBe(1); }); // ── T5 ── no `.wallet-shop` in DOM → init is a quiet no-op ────────────── it('T5: initWalletShop with no `.wallet-shop` root does not throw', () => { fixture.remove(); expect(() => WalletShop.initWalletShop()).not.toThrow(); }); // ── T6 ── clicking BUY pins the tooltip BEFORE opening the guard ────── it('T6: clicking BUY ITEM pins the WalletTooltips (so the item ' + 'tooltip stays visible while the guard portal is open)', () => { WalletShop.initWalletShop(); const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn'); btn.click(); // pin() called BEFORE showGuard so the tooltip never has a // chance to hide between the click + the guard render. expect(window.WalletTooltips.pin).toHaveBeenCalled(); expect(window.showGuard).toHaveBeenCalled(); }); // ── T7 ── guard onConfirm unpins the tooltip ─────────────────────────── it('T7: invoking the onConfirm callback unpins the WalletTooltips', () => { // Block fetch like in T3 to avoid Stripe-chain async cleanup pollution. window.fetch = jasmine.createSpy('fetch').and.returnValue(new Promise(() => {})); WalletShop.initWalletShop(); const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn'); btn.click(); const onConfirm = window.showGuard.calls.mostRecent().args[2]; onConfirm(); expect(window.WalletTooltips.unpin).toHaveBeenCalled(); }); // ── T8 ── guard onDismiss unpins the tooltip ─────────────────────────── it('T8: invoking the onDismiss callback unpins the WalletTooltips', () => { WalletShop.initWalletShop(); const btn = fixture.querySelector('#id_shop_tithe-1 .tt-buy-btn'); btn.click(); const onDismiss = window.showGuard.calls.mostRecent().args[3]; expect(typeof onDismiss).toBe('function'); onDismiss(); expect(window.WalletTooltips.unpin).toHaveBeenCalled(); }); });