feat: wallet Shop applet — tile grid + BUY-ITEM microbutton + Stripe.js wiring — Chunk 4 of [[project-wallet-shop-expansion]]. The shop applet (slug wallet-shop, seeded in Chunk 2) now renders the catalog as a horizontal grid of .shop-tile icons: tithe-1 ($1, fa-piggy-bank), tithe-5 ($4, fa-piggy-bank w. ×5 badge), band-1 ($20, fa-ring). Each tile hosts a hover-portaled tooltip carrying name + description + price + a .tt-microbutton-portal w. a .btn-primary BUY ITEM button — clicking opens #id_guard_portal w. "Buy {name} for ${price}?" prompt; confirming triggers Stripe.js confirmCardPayment then POSTs to /shop/confirm + reloads. Items where the user's owned-count has hit max_owned (eg. BAND, owned=1, cap=1) render w. .btn-disabled + × glyph + "Already owned" microtooltip text — visible-but-unbuyable per the locked decision. View context — wallet view + toggle_wallet_applets view both pass shop_items (decorated w. per-user .available via the new _shop_items_for(user) helper) + default_payment_method_id + stripe_publishable_key. SCSS — .wallet-shop (flex column wrapping .shop-grid flex row), .shop-tile (inline-flex tooltip target), .shop-badge (2rem circle, --quaUser glyph on --quiUser bg, top-right corner per spec), .tt-microbutton-portal (column-flex, BUY btn + 'Already owned' caption styling). JS in wallet-shop.js exposes a singleton WalletShop module (matching the project's Brief / SeaDeal / StageCard module pattern) w. a tested initWalletShop() method — uses event delegation on the shop root (so portal-relocated buy btns still hit the handler) + a DOM-keyed data-shop-wired flag (not a module-level boolean) so per-test fixture rebuilds re-wire cleanly. Wired into wallet.html after wallet.js. **TDD** — 5 Jasmine specs in WalletShopSpec.js: T1 click-on-enabled-BUY opens guard w. correct prompt; T2 click-on-disabled-BUY no-op; T3 onConfirm POSTs shop_item_slug to /shop/buy; T4 init idempotent (calling twice doesn't double-wire); T5 missing-root no-throw. **2 Jasmine traps caught**: (a) spyOn(window, 'fetch') collides if another spec already spied on fetch — switched to save+restore via per-test _origFetch capture; (b) T3 async pollution — sync assertion passed, afterEach restored window.Stripe=undefined, then _doBuy's async continuation hit Stripe(pubKey) and threw "Unhandled promise rejection". Fixed by T3-local fetch mock returning a never-resolving promise so the chain pauses at the first await. **3 new FTs** in test_dash_wallet.py: tiles + icons + ×5 badge + tooltip prose; BUY click opens guard portal + NVM dismisses; BAND-already-owned shows disabled BUY w. 'Already owned' microtext (reads via textContent since .tt is display: none). FT trap caught: TransactionTestCase wipes both migration-seeded Applets + ShopItems → setUp must re-seed both manually (mirrors test_shop_views.py's _seed_starting_items pattern). 1208 IT/UT + 9 wallet FTs + 5 Jasmine specs green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ from selenium.webdriver.common.by import By
|
||||
|
||||
from .base import FunctionalTest
|
||||
from apps.applets.models import Applet
|
||||
from apps.lyric.models import Token, User
|
||||
from apps.lyric.models import ShopItem, Token, User
|
||||
|
||||
|
||||
class WalletDisplayTest(FunctionalTest):
|
||||
@@ -14,11 +14,47 @@ class WalletDisplayTest(FunctionalTest):
|
||||
("wallet-balances", "Wallet Balances", 3, 3),
|
||||
("wallet-tokens", "Wallet Tokens", 3, 3),
|
||||
("wallet-payment", "Payment Methods", 6, 3),
|
||||
("wallet-shop", "Shop", 12, 3),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
slug=slug,
|
||||
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "wallet"},
|
||||
)
|
||||
# Seed the 3 starting ShopItems — migration `lyric/0009_seed_shop_items`
|
||||
# populates these in fresh DBs, but FTs run on `TransactionTestCase`
|
||||
# which wipes all data between tests (post-migrate signal pollution
|
||||
# doesn't restore RunPython-seeded rows). Mirror the migration's
|
||||
# locked row shapes here.
|
||||
ShopItem.objects.update_or_create(
|
||||
slug="tithe-1",
|
||||
defaults={
|
||||
"name": "Tithe Token", "description": "1 Tithe + 144 Writs",
|
||||
"icon": "fa-piggy-bank", "badge_text": "",
|
||||
"price_cents": 100, "granted_token_type": Token.TITHE,
|
||||
"granted_count": 1, "granted_writs": 144,
|
||||
"max_owned": None, "display_order": 10, "active": True,
|
||||
},
|
||||
)
|
||||
ShopItem.objects.update_or_create(
|
||||
slug="tithe-5",
|
||||
defaults={
|
||||
"name": "Tithe Bundle", "description": "5 Tithe Tokens + 750 Writs",
|
||||
"icon": "fa-piggy-bank", "badge_text": "×5",
|
||||
"price_cents": 400, "granted_token_type": Token.TITHE,
|
||||
"granted_count": 5, "granted_writs": 750,
|
||||
"max_owned": None, "display_order": 20, "active": True,
|
||||
},
|
||||
)
|
||||
ShopItem.objects.update_or_create(
|
||||
slug="band-1",
|
||||
defaults={
|
||||
"name": "Wristband", "description": "Admit All Entry",
|
||||
"icon": "fa-ring", "badge_text": "",
|
||||
"price_cents": 2000, "granted_token_type": Token.BAND,
|
||||
"granted_count": 1, "granted_writs": 0,
|
||||
"max_owned": 1, "display_order": 30, "active": True,
|
||||
},
|
||||
)
|
||||
|
||||
def test_new_user_wallet_shows_starting_balances(self):
|
||||
# 1. Log in as new user
|
||||
@@ -224,6 +260,94 @@ class WalletDisplayTest(FunctionalTest):
|
||||
self.assertIn("no expiry", carte_tt)
|
||||
|
||||
|
||||
def test_shop_applet_renders_seeded_items_with_icons_and_badges(self):
|
||||
"""Chunk 4 of [[project-wallet-shop-expansion]] — the new Shop
|
||||
applet renders the 3 seeded items (`tithe-1`, `tithe-5`, `band-1`)
|
||||
as tooltipped tiles. The bundle (`tithe-5`) carries a `×5` badge;
|
||||
the single-tithe + band tiles render without a badge.
|
||||
|
||||
Pinned visual contract: tile presence (id), icon class, badge text,
|
||||
and price text inside the tooltip prose."""
|
||||
self.create_pre_authenticated_session("capman@test.io")
|
||||
self.browser.get(self.live_server_url + "/dashboard/wallet/")
|
||||
# 1. Shop applet is present
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
|
||||
# 2. Each of the 3 seeded items renders w. its own tile
|
||||
tithe1 = self.browser.find_element(By.ID, "id_shop_tithe-1")
|
||||
tithe5 = self.browser.find_element(By.ID, "id_shop_tithe-5")
|
||||
band1 = self.browser.find_element(By.ID, "id_shop_band-1")
|
||||
# 3. Icons are correct
|
||||
self.assertIn("fa-piggy-bank", tithe1.find_element(By.CSS_SELECTOR, "i").get_attribute("class"))
|
||||
self.assertIn("fa-piggy-bank", tithe5.find_element(By.CSS_SELECTOR, "i").get_attribute("class"))
|
||||
self.assertIn("fa-ring", band1.find_element(By.CSS_SELECTOR, "i").get_attribute("class"))
|
||||
# 4. Bundle carries the ×5 badge; singles don't
|
||||
self.assertIn("×5", tithe5.find_element(By.CSS_SELECTOR, ".shop-badge").text)
|
||||
self.assertEqual(tithe1.find_elements(By.CSS_SELECTOR, ".shop-badge"), [])
|
||||
self.assertEqual(band1.find_elements(By.CSS_SELECTOR, ".shop-badge"), [])
|
||||
# 5. Tooltip prose includes name + price (read .tt innerHTML directly
|
||||
# — hover→portal cloning is already exercised by the existing
|
||||
# COIN/FREE hover tests).
|
||||
tithe1_tt = tithe1.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML")
|
||||
self.assertIn("Tithe Token", tithe1_tt)
|
||||
self.assertIn("$1", tithe1_tt)
|
||||
band1_tt = band1.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML")
|
||||
self.assertIn("Wristband", band1_tt)
|
||||
self.assertIn("$20", band1_tt)
|
||||
|
||||
def test_shop_buy_click_opens_guard_portal_with_purchase_prompt(self):
|
||||
"""BUY ITEM click opens `#id_guard_portal` w. an 'Are you sure?'-style
|
||||
prompt naming the item + price. Click NVM dismisses; click OK
|
||||
triggers the Stripe.js dance (mocked out / deferred to a follow-up
|
||||
FT)."""
|
||||
self.create_pre_authenticated_session("capman@test.io")
|
||||
self.browser.get(self.live_server_url + "/dashboard/wallet/")
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
|
||||
# 1. Find BUY ITEM btn inside the tithe-1 tile's microtooltip
|
||||
buy_btn = self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_shop_tithe-1 .tt-buy-btn"
|
||||
)
|
||||
# 2. Click it via JS (microtooltip lives in the portal layer + may
|
||||
# not be hover-visible during Selenium's strict-target check)
|
||||
self.browser.execute_script("arguments[0].click();", buy_btn)
|
||||
# 3. Guard portal opens w. a prompt mentioning Tithe + $1
|
||||
portal = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_guard_portal")
|
||||
)
|
||||
self.wait_for(
|
||||
lambda: self.assertIn("active", portal.get_attribute("class"))
|
||||
)
|
||||
msg = portal.find_element(By.CSS_SELECTOR, ".guard-message").text
|
||||
self.assertIn("Tithe", msg)
|
||||
self.assertIn("$1", msg)
|
||||
# 4. NVM dismisses
|
||||
portal.find_element(By.CSS_SELECTOR, ".guard-no").click()
|
||||
self.wait_for(
|
||||
lambda: self.assertNotIn("active", portal.get_attribute("class"))
|
||||
)
|
||||
|
||||
def test_shop_band_already_owned_shows_disabled_buy_btn(self):
|
||||
"""User who already owns 1 BAND (`max_owned=1`) sees the band-1
|
||||
tile w. its BUY btn disabled + an 'Already owned' microtooltip
|
||||
— visible-but-unbuyable per the decision in [[project-wallet-
|
||||
shop-expansion]]."""
|
||||
owner = User.objects.create(email="bander@test.io")
|
||||
Token.objects.create(user=owner, token_type=Token.BAND)
|
||||
self.create_pre_authenticated_session("bander@test.io")
|
||||
self.browser.get(self.live_server_url + "/dashboard/wallet/")
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_wallet_shop"))
|
||||
band_tile = self.browser.find_element(By.ID, "id_shop_band-1")
|
||||
# BUY btn rendered as `.btn-disabled` w. × glyph (parity w. game-
|
||||
# kit's disabled DON/DOFF buttons). Read via `textContent` because
|
||||
# `.tt` is `display: none` by default + Selenium's `.text` returns
|
||||
# empty for hidden subtrees.
|
||||
buy_btn = band_tile.find_element(By.CSS_SELECTOR, ".tt-buy-btn")
|
||||
self.assertIn("btn-disabled", buy_btn.get_attribute("class"))
|
||||
self.assertEqual(buy_btn.get_attribute("textContent").strip(), "×")
|
||||
# Microtooltip swap signals why it's disabled
|
||||
tt_html = band_tile.find_element(By.CSS_SELECTOR, ".tt").get_attribute("innerHTML")
|
||||
self.assertIn("Already owned", tt_html)
|
||||
|
||||
|
||||
def test_user_can_purchase_tithe_token_bundle(self):
|
||||
# 1. Log in, navigate to wallet page
|
||||
self.create_pre_authenticated_session("capman@test.io")
|
||||
|
||||
Reference in New Issue
Block a user