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