My Sea iter 6a: gatekeeper page + INSERT/REFUND/PAID DRAW endpoints + MySeaDraw deposit fields + _select_my_sea_token / debit_my_sea_token helpers (CARTE blocked, COIN 24h cooldown not 7-day) + Sprint 6 FT skeleton — Sprint 5 iter 6a of My Sea roadmap — TDD
First of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Replaces the iter-4c 404 stub at `/gameboard/my-sea/gate/` w. a real token-deposit-to-redraw UI. Iter 6b will wire the navbar GATE VIEW swap + landing PAID DRAW state + seat-1 persistence; iter 6c will land the bud-btn stub. ## Server `MySeaDraw` gains two fields: `deposit_token_id` (int, nullable) + `deposit_reserved_at` (datetime, nullable). Migration 0002. The row plays triple duty now: hand storage + 24h quota tracker + deposit reservation slot. `_select_my_sea_token(user)` mirrors `apps.epic.models.select_token` priority (PASS > COIN > FREE > TITHE) w. two adaptations: - CARTE excluded outright (door-spell trinket, not valid for my-sea draws). - COIN cooldown-respecting: filters out COINs w. `next_ready_at > now`. Standard `select_token` doesn't apply this filter — room logic unchanged. `debit_my_sea_token(user, token)` is the my-sea variant of `apps.epic.models.debit_token`: - CARTE → ValueError (defensive; caller validates upstream). - COIN: `next_ready_at = now + 24h` (not 7-day room cycle) + unequip from kit if equipped. - PASS: no consumption (auto-admit, unlimited redraws). - FREE / TITHE: deleted. `my_sea_gate` view replaces the 404 stub. Renders the gatekeeper template w. branching on `deposit_reserved` (token reserved on row vs not). `my_sea_insert_token` POST: picks a token via `_select_my_sea_token` + sets `deposit_token_id + deposit_reserved_at`. Creates the row if missing (so a fresh user can deposit without first using their free draw). Idempotent w.r.t. an already-reserved deposit. `my_sea_refund_token` POST: clears deposit fields. Token isn't consumed at INSERT (refund-aware design), so this is purely a row update — no inventory side effects. `my_sea_paid_draw` POST: commits via `debit_my_sea_token` + resets row (hand=[], created_at=now, deposit fields cleared). Redirects to `/gameboard/my-sea/` for a fresh quota cycle. ## Template + UX `apps/gameboard/my_sea_gate.html` (new) — per user spec 2026-05-20, the gatekeeper is a darkened-modal-over-`--duoUser` bg matching the room gatekeeper's chrome (`.gate-backdrop` + `.gate-overlay` + `.gate-modal`). No hex / chair-seats — those live on the my-sea picker page itself; the gatekeeper is a transient in-flight UI for token deposit. Coin-slot rails (mirrors room's `.token-slot`): - Pre-deposit: form-wrapped `.token-rails` button → POSTs to `my_sea_insert_token`. Coin-panel labels read INSERT TOKEN TO PLAY. - Post-deposit: rails inert (no form); `.token-return-btn` form → POSTs to `my_sea_refund_token`. Coin-panel labels swap to PUSH TO RETURN. - Post-deposit: PAID DRAW btn (`#id_my_sea_paid_draw_btn`, `.btn-primary`) → POSTs to `my_sea_paid_draw`. Mirrors the room's PICK ROLES btn shape. SCSS minimal — page bg `rgba(--duoUser, 1)` on `.my-sea-page[data-phase="gate"]`; everything else reuses the room gatekeeper's existing rules. ## FT skeleton Per user TDD directive (2026-05-20: "Also via TDD so if we run out we're adhering to FT-described behavior"), wrote the FULL Sprint 6 FT skeleton up front (covers iter 6a + 6b + 6c). Five new FT classes in `test_game_my_sea.py`: - `MySeaGatekeeperPageTest` (5 tests) — iter 6a; pre-deposit / INSERT / REFUND / PAID DRAW paths. - `MySeaLandingPaidDrawTest` (1 test) — iter 6b; landing renders PAID DRAW btn when deposit reserved (red until iter 6b lands). - `MySeaNavbarGateViewTest` (1 test) — iter 6b; navbar GATE VIEW swap (red until iter 6b). - `MySeaSeatOnePersistenceTest` (2 tests) — iter 6b; seat 1 banned for fresh user + empty-hand active draw (red until iter 6b). - `MySeaBudBtnStubTest` (2 tests) — iter 6c; panel opens + OK shows coming-soon Brief (red until iter 6c). ## ITs (iter 6a — 22 new + 153 total green) - `MySeaGateViewTest` (4) — view branching pre/post deposit. - `MySeaInsertTokenViewTest` (4) — row creation, existing row, idempotency, GET=405. - `MySeaRefundTokenViewTest` (3) — clears fields, no token consumption, idempotent. - `MySeaPaidDrawViewTest` (6) — FREE consumed, COIN cooldown + unequip, PASS no-op, hand reset, created_at reset, redirect. - `SelectMySeaTokenTest` (3) — CARTE excluded, COIN cooldown excluded, PASS priority for staff. - `DebitMySeaTokenTest` (4) — CARTE ValueError, FREE/TITHE consumed, PASS preserved. ## Trap caught Existing User `post_save` signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`). Sprint 6 ITs that assert "user has only the token I seeded" must `self.user.tokens.all().delete()` after User.create. Without it, `_select_my_sea_token` returns the auto-COIN instead of None for the CARTE-excluded test. Worth a future feedback memory if it bites again. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import lxml.html
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.applets.models import Applet, UserApplet
|
||||
from apps.epic.models import DeckVariant, Room, TableSeat
|
||||
@@ -1368,21 +1370,325 @@ class MySeaViewWithPartialHandTest(TestCase):
|
||||
self.assertNotIn("my-sea-picker--locked", m.group(1))
|
||||
|
||||
|
||||
class MySeaGateStubViewTest(TestCase):
|
||||
"""Sprint 5 iter 4c — placeholder for the Sprint 6 gatekeeper. Returns
|
||||
a 404 so the template-side GATE VIEW button URL resolves but the
|
||||
actual gatekeeper UX rides Sprint 6."""
|
||||
class MySeaGateViewTest(TestCase):
|
||||
"""Sprint 6 iter 6a — `my_sea_gate` renders the solo gatekeeper UI.
|
||||
Replaces the iter-4c 404 stub. Branches on whether a deposit is
|
||||
already reserved on the user's MySeaDraw row."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
from datetime import timedelta
|
||||
from apps.lyric.models import Token
|
||||
self.user = User.objects.create(email="gate@test.io")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_gate_view_returns_404(self):
|
||||
response = self.client.get(reverse("my_sea_gate"))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.target = personal_sig_cards(self.user)[0]
|
||||
self.user.significator = self.target
|
||||
self.user.save(update_fields=["significator"])
|
||||
# Seed a FREE token so deposit attempts have something to pick.
|
||||
Token.objects.create(
|
||||
user=self.user, token_type=Token.FREE,
|
||||
expires_at=timezone.now() + timedelta(days=30),
|
||||
)
|
||||
|
||||
def test_gate_view_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("my_sea_gate"))
|
||||
# @login_required redirects before the 404 path runs.
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_gate_view_renders_200(self):
|
||||
response = self.client.get(reverse("my_sea_gate"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "apps/gameboard/my_sea_gate.html")
|
||||
|
||||
def test_gate_view_shows_insert_token_form_when_no_deposit(self):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
significator_id=self.target.id, hand=[],
|
||||
)
|
||||
response = self.client.get(reverse("my_sea_gate"))
|
||||
self.assertContains(response, reverse("my_sea_insert_token"))
|
||||
self.assertNotContains(response, "id_my_sea_paid_draw_btn")
|
||||
|
||||
def test_gate_view_shows_paid_draw_btn_when_deposit_reserved(self):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
from apps.lyric.models import Token
|
||||
free_tok = Token.objects.filter(user=self.user, token_type=Token.FREE).first()
|
||||
MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
significator_id=self.target.id, hand=[],
|
||||
deposit_token_id=free_tok.pk,
|
||||
deposit_reserved_at=timezone.now(),
|
||||
)
|
||||
response = self.client.get(reverse("my_sea_gate"))
|
||||
self.assertContains(response, "id_my_sea_paid_draw_btn")
|
||||
self.assertContains(response, reverse("my_sea_refund_token"))
|
||||
|
||||
|
||||
class MySeaInsertTokenViewTest(TestCase):
|
||||
"""Sprint 6 iter 6a — POST `/gameboard/my-sea/insert` reserves the
|
||||
user's next-priority token on their MySeaDraw row (creates the row
|
||||
if missing). Idempotent w.r.t. an already-reserved deposit."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
self.user = User.objects.create(email="insert@test.io")
|
||||
# Wipe auto-tokens from User post_save signal (COIN + FREE).
|
||||
self.user.tokens.all().delete()
|
||||
self.client.force_login(self.user)
|
||||
self.target = personal_sig_cards(self.user)[0]
|
||||
self.user.significator = self.target
|
||||
self.user.save(update_fields=["significator"])
|
||||
self.free_tok = Token.objects.create(
|
||||
user=self.user, token_type=Token.FREE,
|
||||
expires_at=timezone.now() + timedelta(days=30),
|
||||
)
|
||||
self.url = reverse("my_sea_insert_token")
|
||||
|
||||
def test_insert_get_returns_405(self):
|
||||
self.assertEqual(self.client.get(self.url).status_code, 405)
|
||||
|
||||
def test_insert_creates_row_and_reserves_token(self):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
self.client.post(self.url)
|
||||
draw = MySeaDraw.objects.get(user=self.user)
|
||||
self.assertEqual(draw.deposit_token_id, self.free_tok.pk)
|
||||
self.assertIsNotNone(draw.deposit_reserved_at)
|
||||
|
||||
def test_insert_uses_existing_row_when_one_exists(self):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
draw = MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
significator_id=self.target.id, hand=[],
|
||||
)
|
||||
self.client.post(self.url)
|
||||
draw.refresh_from_db()
|
||||
self.assertEqual(draw.deposit_token_id, self.free_tok.pk)
|
||||
self.assertEqual(MySeaDraw.objects.filter(user=self.user).count(), 1)
|
||||
|
||||
def test_insert_idempotent_when_deposit_already_reserved(self):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
from apps.lyric.models import Token
|
||||
other_tok = Token.objects.create(
|
||||
user=self.user, token_type=Token.FREE,
|
||||
expires_at=timezone.now() + timedelta(days=30),
|
||||
)
|
||||
draw = MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
significator_id=self.target.id, hand=[],
|
||||
deposit_token_id=other_tok.pk,
|
||||
deposit_reserved_at=timezone.now(),
|
||||
)
|
||||
self.client.post(self.url)
|
||||
draw.refresh_from_db()
|
||||
# Still pointed at the original token; no double-reserve.
|
||||
self.assertEqual(draw.deposit_token_id, other_tok.pk)
|
||||
|
||||
|
||||
class MySeaRefundTokenViewTest(TestCase):
|
||||
"""Sprint 6 iter 6a — POST `/gameboard/my-sea/refund` clears the
|
||||
deposit fields. Token wasn't actually consumed at INSERT (refund-
|
||||
aware design), so no inventory side effects."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
from datetime import timedelta
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
from apps.lyric.models import Token
|
||||
self.user = User.objects.create(email="refund@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.target = personal_sig_cards(self.user)[0]
|
||||
self.user.significator = self.target
|
||||
self.user.save(update_fields=["significator"])
|
||||
self.tok = Token.objects.create(
|
||||
user=self.user, token_type=Token.FREE,
|
||||
expires_at=timezone.now() + timedelta(days=30),
|
||||
)
|
||||
self.draw = MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
significator_id=self.target.id, hand=[],
|
||||
deposit_token_id=self.tok.pk,
|
||||
deposit_reserved_at=timezone.now(),
|
||||
)
|
||||
self.url = reverse("my_sea_refund_token")
|
||||
|
||||
def test_refund_clears_deposit_fields(self):
|
||||
self.client.post(self.url)
|
||||
self.draw.refresh_from_db()
|
||||
self.assertIsNone(self.draw.deposit_token_id)
|
||||
self.assertIsNone(self.draw.deposit_reserved_at)
|
||||
|
||||
def test_refund_does_not_consume_token(self):
|
||||
from apps.lyric.models import Token
|
||||
self.client.post(self.url)
|
||||
self.assertTrue(Token.objects.filter(pk=self.tok.pk).exists())
|
||||
|
||||
def test_refund_idempotent_when_no_deposit(self):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
MySeaDraw.objects.all().delete()
|
||||
response = self.client.post(self.url)
|
||||
self.assertIn(response.status_code, (200, 204, 302))
|
||||
|
||||
|
||||
class MySeaPaidDrawViewTest(TestCase):
|
||||
"""Sprint 6 iter 6a — POST `/gameboard/my-sea/paid-draw` commits the
|
||||
deposited token + resets the row for a fresh 24h quota cycle. Per-
|
||||
token-type debit rules apply (FREE/TITHE consumed, COIN cooldown,
|
||||
PASS no-op, CARTE not reachable via `_select_my_sea_token`)."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
from datetime import timedelta
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
from apps.lyric.models import Token
|
||||
self.user = User.objects.create(email="paid@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.target = personal_sig_cards(self.user)[0]
|
||||
self.user.significator = self.target
|
||||
self.user.save(update_fields=["significator"])
|
||||
self.free_tok = Token.objects.create(
|
||||
user=self.user, token_type=Token.FREE,
|
||||
expires_at=timezone.now() + timedelta(days=30),
|
||||
)
|
||||
self.draw = MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
significator_id=self.target.id, hand=[],
|
||||
deposit_token_id=self.free_tok.pk,
|
||||
deposit_reserved_at=timezone.now(),
|
||||
)
|
||||
self.url = reverse("my_sea_paid_draw")
|
||||
|
||||
def test_paid_draw_consumes_free_token(self):
|
||||
from apps.lyric.models import Token
|
||||
self.client.post(self.url)
|
||||
self.assertFalse(Token.objects.filter(pk=self.free_tok.pk).exists())
|
||||
|
||||
def test_paid_draw_clears_deposit_fields_and_resets_created_at(self):
|
||||
old_created = self.draw.created_at
|
||||
# Push old created_at back so reset is observable.
|
||||
from datetime import timedelta
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
MySeaDraw.objects.filter(pk=self.draw.pk).update(
|
||||
created_at=old_created - timedelta(hours=12),
|
||||
)
|
||||
self.client.post(self.url)
|
||||
self.draw.refresh_from_db()
|
||||
self.assertIsNone(self.draw.deposit_token_id)
|
||||
self.assertIsNone(self.draw.deposit_reserved_at)
|
||||
self.assertGreater(self.draw.created_at, old_created - timedelta(hours=12))
|
||||
|
||||
def test_paid_draw_resets_hand_to_empty(self):
|
||||
# Even if hand is non-empty, PAID DRAW wipes it (fresh draw cycle).
|
||||
from apps.epic.models import TarotCard
|
||||
cards = list(TarotCard.objects.exclude(id=self.target.id)[:2])
|
||||
self.draw.hand = [
|
||||
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
|
||||
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
|
||||
]
|
||||
self.draw.save(update_fields=["hand"])
|
||||
self.client.post(self.url)
|
||||
self.draw.refresh_from_db()
|
||||
self.assertEqual(self.draw.hand, [])
|
||||
|
||||
def test_paid_draw_with_coin_sets_24h_cooldown_and_unequips(self):
|
||||
from datetime import timedelta
|
||||
from apps.lyric.models import Token
|
||||
coin = Token.objects.create(user=self.user, token_type=Token.COIN)
|
||||
self.user.equipped_trinket = coin
|
||||
self.user.save(update_fields=["equipped_trinket"])
|
||||
self.draw.deposit_token_id = coin.pk
|
||||
self.draw.save(update_fields=["deposit_token_id"])
|
||||
before = timezone.now()
|
||||
self.client.post(self.url)
|
||||
coin.refresh_from_db()
|
||||
self.assertTrue(coin.next_ready_at >= before + timedelta(hours=23, minutes=58))
|
||||
self.assertTrue(coin.next_ready_at <= before + timedelta(hours=24, minutes=2))
|
||||
self.user.refresh_from_db()
|
||||
self.assertIsNone(self.user.equipped_trinket_id)
|
||||
|
||||
def test_paid_draw_with_pass_does_not_consume(self):
|
||||
from apps.lyric.models import Token
|
||||
pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS)
|
||||
self.draw.deposit_token_id = pass_tok.pk
|
||||
self.draw.save(update_fields=["deposit_token_id"])
|
||||
self.client.post(self.url)
|
||||
self.assertTrue(Token.objects.filter(pk=pass_tok.pk).exists())
|
||||
|
||||
def test_paid_draw_no_deposit_redirects_to_my_sea(self):
|
||||
self.draw.deposit_token_id = None
|
||||
self.draw.save(update_fields=["deposit_token_id"])
|
||||
response = self.client.post(self.url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
|
||||
class SelectMySeaTokenTest(TestCase):
|
||||
"""Sprint 6 iter 6a — `_select_my_sea_token` priority chain w. CARTE
|
||||
excluded + COIN cooldown-respecting."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="selecttok@test.io")
|
||||
# New-user post_save signal auto-creates COIN + FREE tokens
|
||||
# (`apps.lyric.models`). Wipe them so each test only sees the
|
||||
# tokens it explicitly seeds.
|
||||
self.user.tokens.all().delete()
|
||||
|
||||
def test_carte_is_excluded(self):
|
||||
from apps.gameboard.models import _select_my_sea_token
|
||||
Token.objects.create(user=self.user, token_type=Token.CARTE)
|
||||
self.assertIsNone(_select_my_sea_token(self.user))
|
||||
|
||||
def test_cooldown_coin_is_excluded(self):
|
||||
from apps.gameboard.models import _select_my_sea_token
|
||||
Token.objects.create(
|
||||
user=self.user, token_type=Token.COIN,
|
||||
next_ready_at=timezone.now() + timedelta(hours=12),
|
||||
)
|
||||
self.assertIsNone(_select_my_sea_token(self.user))
|
||||
|
||||
def test_pass_wins_priority_for_staff(self):
|
||||
from apps.lyric.models import Token
|
||||
from apps.gameboard.models import _select_my_sea_token
|
||||
self.user.is_staff = True
|
||||
self.user.save(update_fields=["is_staff"])
|
||||
pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS)
|
||||
Token.objects.create(user=self.user, token_type=Token.COIN)
|
||||
self.assertEqual(_select_my_sea_token(self.user), pass_tok)
|
||||
|
||||
|
||||
class DebitMySeaTokenTest(TestCase):
|
||||
"""Sprint 6 iter 6a — `debit_my_sea_token` per-type semantics."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="debittok@test.io")
|
||||
|
||||
def test_carte_raises_value_error(self):
|
||||
from apps.lyric.models import Token
|
||||
from apps.gameboard.models import debit_my_sea_token
|
||||
carte = Token.objects.create(user=self.user, token_type=Token.CARTE)
|
||||
with self.assertRaises(ValueError):
|
||||
debit_my_sea_token(self.user, carte)
|
||||
|
||||
def test_free_token_is_consumed(self):
|
||||
from datetime import timedelta
|
||||
from apps.lyric.models import Token
|
||||
from apps.gameboard.models import debit_my_sea_token
|
||||
free = Token.objects.create(
|
||||
user=self.user, token_type=Token.FREE,
|
||||
expires_at=timezone.now() + timedelta(days=30),
|
||||
)
|
||||
debit_my_sea_token(self.user, free)
|
||||
self.assertFalse(Token.objects.filter(pk=free.pk).exists())
|
||||
|
||||
def test_tithe_token_is_consumed(self):
|
||||
from apps.lyric.models import Token
|
||||
from apps.gameboard.models import debit_my_sea_token
|
||||
tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
|
||||
debit_my_sea_token(self.user, tithe)
|
||||
self.assertFalse(Token.objects.filter(pk=tithe.pk).exists())
|
||||
|
||||
def test_pass_token_is_not_consumed(self):
|
||||
from apps.lyric.models import Token
|
||||
from apps.gameboard.models import debit_my_sea_token
|
||||
pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS)
|
||||
debit_my_sea_token(self.user, pass_tok)
|
||||
self.assertTrue(Token.objects.filter(pk=pass_tok.pk).exists())
|
||||
|
||||
Reference in New Issue
Block a user