from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By from .base import FunctionalTest from apps.applets.models import Applet from apps.lyric.models import ShopItem, Token, User class WalletDisplayTest(FunctionalTest): def setUp(self): super().setUp() Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"}) for slug, name, cols, rows in [ ("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 self.create_pre_authenticated_session("capman@test.io") # 2. Navigate to dashboard self.browser.get(self.live_server_url) # 3. Find wallet applet summary card self.wait_for( lambda: self.browser.find_element(By.ID, "id_applet_wallet") ) # 4. Click thru to full wallet page via manage link self.browser.find_element( By.CSS_SELECTOR, "#id_applet_wallet a.wallet-manage-link" ).click() # 5. Assert user landed on wallet page self.wait_for( lambda: self.assertRegex(self.browser.current_url, r"/dashboard/wallet/$") ) # 6. Assert writs balance shows 144 (complimentary bundle on signup) self.wait_for( lambda: self.assertEqual( "144", self.browser.find_element(By.ID, "id_writs_balance").text, ) ) # 7. Assert esteem balance shows 0 self.assertEqual( "0", self.browser.find_element(By.ID, "id_esteem_balance").text, ) # 8. Assert Coin-on-a-String token element present coin = self.browser.find_element(By.ID, "id_coin_on_a_string") # 9. Hover over it; assert tooltip appears w. name, entry text, 'no expiry' ActionChains(self.browser).move_to_element(coin).perform() self.wait_for( lambda: self.assertTrue( self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed() ) ) coin_tooltip = self.browser.find_element(By.ID, "id_tooltip_portal").text self.assertIn("Coin-on-a-String", coin_tooltip) self.assertIn("Admit 1 Entry", coin_tooltip) self.assertIn("no expiry", coin_tooltip) # 10. Assert ×1 Free Token present (complimentary on signup) free_token = self.browser.find_element(By.ID, "id_free_token") # 11. Hover over it; assert tooltip shows name, entry text, expiry date ActionChains(self.browser).move_to_element(free_token).perform() self.wait_for( lambda: self.assertTrue( self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed() ) ) free_tooltip = self.browser.find_element(By.ID, "id_tooltip_portal").text self.assertIn("Free Token", free_tooltip) self.assertIn("Admit 1 Entry", free_tooltip) self.assertIn("Expires", free_tooltip) def test_wallet_payment_section_renders(self): # 1. Log in, navigate directly to wallet page self.create_pre_authenticated_session("capman@test.io") self.browser.get(self.live_server_url + "/dashboard/wallet/") # 2. Assert saved payment methods section present self.wait_for( lambda: self.browser.find_element(By.ID, "id_payment_methods") ) # 3. Assert the add-payment-method button is visible self.browser.find_element(By.ID, "id_add_payment_method") # 4. Assert Stripe Payment Element mount point exists self.browser.find_element(By.ID, "id_stripe_payment_element") def test_user_can_save_a_payment_method(self): # 1. Log in, navigate to wallet page self.create_pre_authenticated_session("capman@test.io") self.browser.get(self.live_server_url + "/dashboard/wallet/") # 2. Click add-payment-method btn self.browser.find_element(By.ID, "id_add_payment_method").click() # 3. Wait for Stripe Payment Element iframe to appear inside mount point self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, "#id_stripe_payment_element iframe" ) ) # 4. Switch into Stripe iframe to interact w. card fields stripe_frame = self.browser.find_element( By.CSS_SELECTOR, "#id_stripe_payment_element iframe" ) self.browser.switch_to.frame(stripe_frame) # 4a. Wait for card inputs to render inside iframe self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, 'input[placeholder="1234 1234 1234 1234"]' ) ) # 5. Fill in Stripe test card details self.browser.find_element( By.CSS_SELECTOR, 'input[placeholder="1234 1234 1234 1234"]' ).send_keys("4242424242424242") self.browser.find_element( By.CSS_SELECTOR, 'input[placeholder="MM / YY"]' ).send_keys("12 / 26") self.browser.find_element( By.CSS_SELECTOR, 'input[placeholder="CVC"]' ).send_keys("424") self.browser.find_element( By.CSS_SELECTOR, 'input[placeholder="12345"]' ).send_keys("42424") # 6. Return to main doc & submit form self.browser.switch_to.default_content() self.wait_for( lambda: self.assertFalse( self.browser.find_element(By.ID, "id_save_payment_method").get_attribute("hidden") ) ) self.browser.find_element(By.ID, "id_save_payment_method").click() # 7. Wait for saved card to appear in payment methods list # Assert last 4 digits shown (Stripe confirmSetup + server round-trip can be slow) self.wait_for_slow( lambda: self.assertIn( "4242", self.browser.find_element(By.ID, "id_payment_methods").text, ) ) def test_user_can_cancel_adding_payment_method(self): # 1. Log in, navigate to wallet page self.create_pre_authenticated_session("capman@test.io") self.browser.get(self.live_server_url + "/dashboard/wallet/") # 2. Click Add Payment Method self.wait_for_slow( lambda: self.browser.find_element(By.ID, "id_add_payment_method") ).click() # 3. Wait for Cancel button to appear (visible after setup-intent fetch returns) self.wait_for_slow( lambda: self.assertFalse( self.browser.find_element(By.ID, "id_cancel_payment_method").get_attribute("hidden") ) ) # 3a. Assert applet expanded to 15 rows rows = self.browser.execute_script( "return document.getElementById('id_payment_methods')" ".style.getPropertyValue('--applet-rows').trim()" ) self.assertEqual(rows, '15') # 4. Click Cancel self.browser.find_element(By.ID, "id_cancel_payment_method").click() # 5. Assert Cancel + Save buttons are hidden again self.wait_for_slow( lambda: self.assertTrue( self.browser.find_element(By.ID, "id_cancel_payment_method").get_attribute("hidden") ) ) self.assertTrue( self.browser.find_element(By.ID, "id_save_payment_method").get_attribute("hidden") ) # 6. Assert applet collapses back to 3 grid rows rows = self.browser.execute_script( "return document.getElementById('id_payment_methods')" ".style.getPropertyValue('--applet-rows').trim()" ) self.assertEqual(rows, '3') def test_wallet_tokens_applet_shows_all_owned_trinket_types(self): """Wallet Tokens applet renders every owned trinket-as-token type independently — PASS (staff), BAND, COIN, CARTE — alongside FREE + TITHE. Pre-2026-05-22 the template had an if/elif chain that only showed ONE of {PASS, BAND, COIN}; that hid BAND + COIN + CARTE entirely from the wallet for any staff user holding a PASS, even though all three are usable at the gate. Chunk 1 of the Shop applet rollout drops that exclusivity so each trinket gets its own tooltipped icon in the row.""" # 1. Build a staff user holding every trinket-as-token type staff = User.objects.create(email="ledger@test.io", is_staff=True) # post_save signal already created COIN (auto-equipped) + FREE + PASS; # mint the rest manually so we exercise the full inventory render. Token.objects.create(user=staff, token_type=Token.BAND) Token.objects.create(user=staff, token_type=Token.CARTE) Token.objects.create(user=staff, token_type=Token.TITHE) # 2. Log in + land on wallet page self.create_pre_authenticated_session("ledger@test.io") self.browser.get(self.live_server_url + "/dashboard/wallet/") # 3. Every trinket-as-token icon is present (no if/elif suppression) self.wait_for(lambda: self.browser.find_element(By.ID, "id_pass_token")) self.browser.find_element(By.ID, "id_band_token") self.browser.find_element(By.ID, "id_coin_on_a_string") self.browser.find_element(By.ID, "id_carte_token") # 4. Consumable tokens still present self.browser.find_element(By.ID, "id_free_token") self.browser.find_element(By.ID, "id_tithe_token") # 5. BAND tile carries its tooltip content in the DOM (the wallet's # `initWalletTooltips` clones `.tt` innerHTML into the portal on # hover — already exercised by the COIN/FREE hover paths in # `test_new_user_wallet_shows_starting_balances`; here we just pin # that the new BAND/CARTE template blocks server-render their full # tooltip prose, which is the Chunk 1 contract). band_tt = self.browser.find_element(By.CSS_SELECTOR, "#id_band_token .tt").get_attribute("innerHTML") self.assertIn("Wristband", band_tt) self.assertIn("Admit All Entry", band_tt) self.assertIn("no expiry", band_tt) # 6. CARTE tile carries its tooltip content too carte_tt = self.browser.find_element(By.CSS_SELECTOR, "#id_carte_token .tt").get_attribute("innerHTML") self.assertIn("Carte Blanche", carte_tt) self.assertIn("Admit up to +6", carte_tt) 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) # Legacy `test_user_can_purchase_tithe_token_bundle` FT (asserting # `#id_tithe_token_shop` inside Balances) was removed in Chunk 5 of # [[project-wallet-shop-expansion]] — the tithe purchase surface # moved to the dedicated Shop applet. Coverage now lives in: # - `test_shop_applet_renders_seeded_items_with_icons_and_badges` # (tile + icon + badge + price) # - `test_shop_buy_click_opens_guard_portal_with_purchase_prompt` # (BUY → guard portal → NVM dismisses) # - `test_shop_band_already_owned_shows_disabled_buy_btn` # (max_owned cap renders BUY as `.btn-disabled` w. microtext)