import lxml.html from django.test import TestCase from apps.applets.models import Applet, UserApplet from apps.lyric.models import Token, User, Wallet class WalletViewTest(TestCase): def setUp(self): self.user = User.objects.create(email="capman@test.io") self.client.force_login(self.user) response = self.client.get("/dashboard/wallet/") self.parsed = lxml.html.fromstring(response.content) def test_wallet_page_requires_login(self): self.client.logout() response = self.client.get("/dashboard/wallet/") self.assertRedirects( response, "/?next=/dashboard/wallet/", fetch_redirect_response=False ) def test_wallet_page_renders(self): [el] = self.parsed.cssselect("#id_writs_balance") self.assertEqual(el.text_content().strip(), "144") def test_wallet_page_shows_esteem_balance(self): [el] = self.parsed.cssselect("#id_esteem_balance") self.assertEqual(el.text_content().strip(), "0") def test_wallet_page_shows_coin_on_a_string(self): [_] = self.parsed.cssselect("#id_coin_on_a_string") def test_wallet_page_shows_free_token(self): [_] = self.parsed.cssselect("#id_free_token") def test_wallet_page_shows_payment_methods_section(self): [_] = self.parsed.cssselect("#id_add_payment_method") def test_wallet_page_shows_stripe_payment_element(self): [_] = self.parsed.cssselect("#id_stripe_payment_element") # Note: the legacy `#id_tithe_token_shop` HTML in Balances was # superseded by the dedicated Shop applet in Chunk 5 of # [[project-wallet-shop-expansion]]. Shop-applet coverage lives in # `WalletTokensAppletAllTrinketsVisibleTest` below + `test_shop_models.py` # + `test_shop_views.py`. class WalletTokensAppletAllTrinketsVisibleTest(TestCase): """Chunk 1 of the Shop applet rollout (2026-05-22) — the Tokens applet in `wallet.html` must show every owned trinket-as-token type at once. Pre-Chunk-1 the template's `{% if pass_token %} ... {% elif band %} ... {% elif coin %}` chain hid two of the three from any user holding a higher-priority trinket — bad UX since all three are usable at the gate (per [[feedback-equip-slot-gates-trinket-use]], the user picks WHICH one fires via the equip slot).""" def setUp(self): self.user = User.objects.create(email="multitoken@test.io", is_staff=True) # Auto-COIN (equipped) + FREE created by post_save signal; PASS auto- # granted by the is_staff branch of the same signal. Add the rest. Token.objects.create(user=self.user, token_type=Token.BAND) Token.objects.create(user=self.user, token_type=Token.CARTE) Token.objects.create(user=self.user, token_type=Token.TITHE) self.client.force_login(self.user) response = self.client.get("/dashboard/wallet/") self.parsed = lxml.html.fromstring(response.content) def test_wallet_shows_pass_token(self): [_] = self.parsed.cssselect("#id_pass_token") def test_wallet_shows_band_token(self): [_] = self.parsed.cssselect("#id_band_token") def test_wallet_shows_coin_on_a_string(self): [_] = self.parsed.cssselect("#id_coin_on_a_string") def test_wallet_shows_carte_token(self): [_] = self.parsed.cssselect("#id_carte_token") def test_wallet_shows_free_token(self): [_] = self.parsed.cssselect("#id_free_token") def test_wallet_shows_tithe_token(self): [_] = self.parsed.cssselect("#id_tithe_token") def test_view_context_passes_carte(self): """Defense-in-depth: not just the template but the view context too — a renamed/refactored template should still receive `carte` in ctx.""" response = self.client.get("/dashboard/wallet/") self.assertEqual(response.context["carte"].token_type, Token.CARTE) def test_view_context_passes_band(self): response = self.client.get("/dashboard/wallet/") self.assertEqual(response.context["band"].token_type, Token.BAND) def test_non_staff_user_with_carte_still_sees_carte(self): """CARTE has no `is_staff` gating (unlike PASS) — a regular gamer holding a CARTE must see it in the Tokens applet.""" non_staff = User.objects.create(email="grunt@test.io") Token.objects.create(user=non_staff, token_type=Token.CARTE) self.client.force_login(non_staff) response = self.client.get("/dashboard/wallet/") parsed = lxml.html.fromstring(response.content) [_] = parsed.cssselect("#id_carte_token") class WalletAppletOrderTest(TestCase): """The wallet row renders Shop first, then Balances/Tokens/Payment in their historical insertion order — pinned via `Applet.display_order` (lower = earlier; default 100 + PK tie-break preserves the legacy order for the rest). Bug-prevention pin: a future migration that renames or reseeds applets must keep wallet-shop at order < 100. See [[project-wallet-shop-expansion]] for the locked layout spec.""" def setUp(self): self.user = User.objects.create(email="layout@test.io") self.client.force_login(self.user) def test_shop_applet_renders_first_in_wallet_row(self): response = self.client.get("/dashboard/wallet/") html = response.content.decode() shop_pos = html.find('id="id_wallet_shop"') balances_pos = html.find('id="id_wallet_balances"') tokens_pos = html.find('id_writs_balance') # inside balances applet # Shop's id_wallet_shop appears before Balances' id_wallet_balances self.assertGreater(shop_pos, 0) self.assertGreater(balances_pos, 0) self.assertLess(shop_pos, balances_pos) self.assertLess(shop_pos, tokens_pos) def test_shop_applet_first_in_context_list(self): """View-context shape pin: `applets` is a list ordered Shop-first.""" response = self.client.get("/dashboard/wallet/") slugs = [e["applet"].slug for e in response.context["applets"]] self.assertEqual(slugs[0], "wallet-shop") class WalletPassTokenVisibilityTest(TestCase): """PASS is admin-only — the model guard blocks bogus rows from existing for non-staff users, but defend the wallet surface too so a future code path that bypasses the model (eg. raw SQL backfill) doesn't silently leak the trinket into a non-admin's view.""" def test_pass_token_in_context_for_staff(self): user = User.objects.create(email="staff@test.io", is_staff=True) self.client.force_login(user) response = self.client.get("/dashboard/wallet/") self.assertIsNotNone(response.context["pass_token"]) def test_pass_token_absent_for_non_staff(self): user = User.objects.create(email="reg@test.io") self.client.force_login(user) response = self.client.get("/dashboard/wallet/") self.assertIsNone(response.context["pass_token"]) def test_pass_token_absent_in_htmx_toggle_partial_for_non_staff(self): Applet.objects.get_or_create( slug="wallet-tokens", defaults={"name": "Wallet Tokens", "grid_cols": 3, "grid_rows": 3, "context": "wallet"}, ) user = User.objects.create(email="reg2@test.io") self.client.force_login(user) response = self.client.post( "/dashboard/wallet/toggle-applets", {"applets": ["wallet-tokens"]}, HTTP_HX_REQUEST="true", ) self.assertIsNone(response.context["pass_token"]) class WalletViewAppletContextTest(TestCase): def setUp(self): self.user = User.objects.create(email="walletctx@test.io") Applet.objects.get_or_create( slug="wallet-balances", defaults={"name": "Wallet Balances", "grid_cols": 3, "grid_rows": 3, "context": "wallet"}, ) Applet.objects.get_or_create( slug="wallet-tokens", defaults={"name": "Wallet Tokens", "grid_cols": 3, "grid_rows": 3, "context": "wallet"}, ) Applet.objects.get_or_create( slug="wallet-payment", defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 3, "context": "wallet"}, ) self.client.force_login(self.user) def test_wallet_view_passes_applets_context(self): response = self.client.get("/dashboard/wallet/") slugs = [e["applet"].slug for e in response.context["applets"]] self.assertIn("wallet-balances", slugs) self.assertIn("wallet-tokens", slugs) self.assertIn("wallet-payment", slugs) def test_wallet_page_renders_applets_container(self): response = self.client.get("/dashboard/wallet/") parsed = lxml.html.fromstring(response.content) [_] = parsed.cssselect("#id_wallet_applets_container") def test_wallet_page_renders_gear_button(self): response = self.client.get("/dashboard/wallet/") parsed = lxml.html.fromstring(response.content) [_] = parsed.cssselect(".gear-btn") class ToggleWalletAppletsTest(TestCase): def setUp(self): self.user = User.objects.create(email="wallettoggle@test.io") self.balances = Applet.objects.get_or_create( slug="wallet-balances", defaults={"name": "Wallet Balances", "grid_cols": 3, "grid_rows": 3, "context": "wallet"}, )[0] self.tokens = Applet.objects.get_or_create( slug="wallet-tokens", defaults={"name": "Wallet Tokens", "grid_cols": 3, "grid_rows": 3, "context": "wallet"}, )[0] Applet.objects.get_or_create( slug="wallet-payment", defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 3, "context": "wallet"}, ) self.client.force_login(self.user) def test_toggle_requires_login(self): self.client.logout() response = self.client.post("/dashboard/wallet/toggle-applets", {}) self.assertRedirects( response, "/?next=/dashboard/wallet/toggle-applets", fetch_redirect_response=False, ) def test_toggle_redirects_to_wallet(self): response = self.client.post( "/dashboard/wallet/toggle-applets", {"applets": ["wallet-balances"]} ) self.assertRedirects(response, "/dashboard/wallet/", fetch_redirect_response=False) def test_toggle_hides_unchecked_applet(self): self.client.post( "/dashboard/wallet/toggle-applets", {"applets": ["wallet-balances"]} ) ua = UserApplet.objects.get(user=self.user, applet=self.tokens) self.assertFalse(ua.visible) def test_toggle_shows_checked_applet(self): UserApplet.objects.create(user=self.user, applet=self.balances, visible=False) self.client.post( "/dashboard/wallet/toggle-applets", {"applets": ["wallet-balances"]} ) ua = UserApplet.objects.get(user=self.user, applet=self.balances) self.assertTrue(ua.visible) def test_toggle_htmx_returns_container_partial(self): response = self.client.post( "/dashboard/wallet/toggle-applets", {"applets": ["wallet-balances"]}, HTTP_HX_REQUEST="true", ) self.assertEqual(response.status_code, 200) self.assertContains(response, "id_wallet_applets_container")