Files
python-tdd/src/apps/dashboard/tests/integrated/test_wallet_views.py

232 lines
9.7 KiB
Python
Raw Normal View History

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")
def test_wallet_page_shows_tithe_token_shop(self):
[_] = self.parsed.cssselect("#id_tithe_token_shop")
def test_tithe_token_shop_shows_bundle(self):
bundles = self.parsed.cssselect("#id_tithe_token_shop .token-bundle")
self.assertGreater(len(bundles), 0)
feat: wallet Tokens applet shows CARTE + BAND + COIN + PASS independently — Chunk 1 of the Shop applet rollout per [[project-wallet-shop-expansion]]. Pre-Chunk-1 the `_applet-wallet-tokens.html` template used a `{% if pass_token %} ... {% elif band %} ... {% elif coin %}` chain that suppressed 2-of-3 trinkets from the wallet whenever the user held a higher-priority one — bad UX since the equip slot is now the user's opt-in for trinket-as-token use per [[feedback-equip-slot-gates-trinket-use]], so ALL owned trinkets need visibility. Fix: dropped the elif chain → independent `{% if %}` blocks for PASS / BAND / COIN; added a new CARTE block w. `fa-money-check` icon mirroring the Game Kit's render. View context (`apps.dashboard.views.wallet` + `:toggle_wallet_applets`) now passes `carte = user.tokens.filter(token_type=Token.CARTE).first()` alongside the existing pass/band/coin keys (no `is_staff` filter — CARTE has no admin gate). TDD — new `WalletTokensAppletAllTrinketsVisibleTest` (9 ITs): 6 pin individual `#id_<token>` visibility for a staff user holding all 5 types, 2 pin view-context shape (`carte` + `band` keys), 1 pins CARTE-on-non-staff. New FT `test_wallet_tokens_applet_shows_all_owned_trinket_types` reads BAND/CARTE `.tt` `innerHTML` directly (no hover ceremony — already covered by the COIN/FREE hover paths in `test_new_user_wallet_shows_starting_balances`) to pin the new template blocks server-render full tooltip prose. **Trap caught mid-build**: initial multi-line `{# ... #}` Django comment leaked as plain text into the rendered DOM (Django's hash-comment is single-line only), pushing the COIN tile off-screen + breaking the existing hover FT. Switched to `{% comment %}...{% endcomment %}`. Captured in [[feedback-django-comments-single-line-only]] — symptom signature: previously-passing Selenium hover times out + screendump shows literal `{# ...` text near the broken element. 1169 IT/UT + 6 wallet FTs green Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:07:42 -04:00
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")
fix: `Token.PASS` is now model-enforced as staff-only — `Token.clean`/`save` raise ValidationError when a non-staff user is the FK target. Staging bug 2026-05-21 — admin awarded a PASS to a non-admin via Django admin; row was created + showed in the user's wallet, but every game-side surface (gameboard, game-kit, gate-pad `select_token`, `_select_my_sea_token`) had always filtered PASS behind `is_staff`, so the token was unequippable + unusable. Five `is_staff`-gated PASS surfaces made PASS a deliberate staff-only trinket; the wallet was the lone outlier surfacing it. Bundled: wallet view (+ HTMX toggle partial) now gates `pass_token` behind `is_staff` mirroring the gameboard pattern — defense-in-depth in case any future bypass writes a stray row. TDD — new ITs: `PassTokenStaffOnlyGuardTest` (model raises for non-staff, accepts for staff, leaves other token types unaffected); `WalletPassTokenVisibilityTest` (3 cases pin wallet + HTMX gating); `TokenAdminFormTest.test_pass_token_for_non_staff_user_is_invalid` + `test_pass_token_for_staff_user_is_valid`. Adjusted 2 existing tests that incidentally exercised the now-blocked pattern (`test_paid_draw_with_pass_does_not_consume`, `test_pass_token_is_not_consumed` — both flip `is_staff = True` inline before `Token.objects.create`); dropped PASS from `test_other_token_types_do_not_require_expires_at`'s loop (covered by the new dedicated tests). 1133 IT/UT green. A non-admin "boost-pass" variant lands as a distinct `token_type` later, NEVER by relaxing the staff gate — captured in [[feedback-pass-token-staff-only]] Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 00:35:55 -04:00
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")