feat: ShopItem + Purchase models + seed tithe-1 / tithe-5 / band-1 + wallet-shop Applet — Chunk 2 of [[project-wallet-shop-expansion]]. ShopItem is the admin-managed catalog: slug, name, description, icon (FA class), badge_text (eg "×5"), price_cents, granted_token_type (any Token type), granted_count, granted_writs (default 0), max_owned (nullable; BAND=1), display_order, active. is_available_for(user) enforces max_owned by comparing user's owned-count of the granted token type. price_display() renders cents → "$1" / "$4.20" for tooltip prose. Purchase is the per-tx audit trail: user + shop_item + stripe_payment_intent_id (unique) + status (PENDING/SUCCEEDED/FAILED/REFUNDED) + amount_cents snapshot + granted_writs snapshot + granted_token_ids JSONField (PKs of minted tokens) + created_at + succeeded_at. fulfill() is idempotent — short-circuits if status==SUCCEEDED + refuses non-PENDING rows so a webhook + sync /shop/confirm racing each other can't double-mint. Schema migration lyric/0008_shopitem_purchase autogenerated. Seed migration lyric/0009_seed_shop_items populates the 3 starting items per locked decisions: tithe-1 ($1 → 1 TITHE + 144 writs, no cap, order=10); tithe-5 ($4 → 5 TITHE + 750 writs, no cap, badge "×5", order=20); band-1 ($20 → 1 BAND + 0 writs, max_owned=1, order=30). Applet migration applets/0011_seed_wallet_shop_applet adds the wallet-shop Applet (context=wallet, 12 cols × 3 rows). Stub _applet-wallet-shop.html lands w. just <section id="id_wallet_shop"> + <h2>Shop</h2> — _applets.html's auto-include-by-slug pattern would 500 the wallet page on TemplateDoesNotExist otherwise (caught mid-Chunk-2 by the full app suite). Chunk 4 fills in the shop-tile grid + BUY-ITEM microtooltip + Stripe.js wiring. TDD — 22 ITs in test_shop_models.py: ShopItemModelTest (9 cases — minimal create, defaults for granted_writs / max_owned / active, is_available_for w/ + w/o max_owned cap, str repr), PurchaseModelTest (8 cases — minimal create, PI ID uniqueness constraint, fulfill mints tokens + grants writs + marks SUCCEEDED + records granted_token_ids + is idempotent on re-fire + creates N tokens for bundle), SeededShopCatalogTest (4 cases pin tithe-1 / tithe-5 / band-1 row shapes + display_order ascending), SeededWalletShopAppletTest (1 case pins Applet seeded). 1191 IT/UT green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
263
src/apps/lyric/tests/integrated/test_shop_models.py
Normal file
263
src/apps/lyric/tests/integrated/test_shop_models.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Shop model ITs — Chunk 2 of the wallet-expansion sprint.
|
||||
|
||||
Pins:
|
||||
* `ShopItem` row shape (slug, name, prose, icon, badge, pricing,
|
||||
granted-token spec, max_owned, display_order, active).
|
||||
* `Purchase` row shape + the `fulfill()` method's idempotent token-
|
||||
mint + writs-grant semantics.
|
||||
* `ShopItem.is_available_for(user)` w/ `max_owned` enforcement.
|
||||
* Seeded catalog: `tithe-1`, `tithe-5`, `band-1` present + correctly
|
||||
configured per the locked decisions in [[project-wallet-shop-
|
||||
expansion]].
|
||||
* Seeded `wallet-shop` Applet present.
|
||||
"""
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.applets.models import Applet
|
||||
from apps.lyric.models import Purchase, ShopItem, Token, User
|
||||
|
||||
|
||||
class ShopItemModelTest(TestCase):
|
||||
def test_create_shopitem_minimal(self):
|
||||
item = ShopItem.objects.create(
|
||||
slug="probe",
|
||||
name="Probe",
|
||||
price_cents=100,
|
||||
granted_token_type=Token.TITHE,
|
||||
granted_count=1,
|
||||
)
|
||||
self.assertEqual(item.slug, "probe")
|
||||
self.assertEqual(item.price_cents, 100)
|
||||
self.assertEqual(item.granted_count, 1)
|
||||
|
||||
def test_granted_writs_defaults_to_zero(self):
|
||||
item = ShopItem.objects.create(
|
||||
slug="probe",
|
||||
name="Probe",
|
||||
price_cents=100,
|
||||
granted_token_type=Token.TITHE,
|
||||
granted_count=1,
|
||||
)
|
||||
self.assertEqual(item.granted_writs, 0)
|
||||
|
||||
def test_max_owned_defaults_to_null(self):
|
||||
item = ShopItem.objects.create(
|
||||
slug="probe",
|
||||
name="Probe",
|
||||
price_cents=100,
|
||||
granted_token_type=Token.TITHE,
|
||||
granted_count=1,
|
||||
)
|
||||
self.assertIsNone(item.max_owned)
|
||||
|
||||
def test_active_defaults_to_true(self):
|
||||
item = ShopItem.objects.create(
|
||||
slug="probe",
|
||||
name="Probe",
|
||||
price_cents=100,
|
||||
granted_token_type=Token.TITHE,
|
||||
granted_count=1,
|
||||
)
|
||||
self.assertTrue(item.active)
|
||||
|
||||
def test_str_returns_name(self):
|
||||
item = ShopItem.objects.create(
|
||||
slug="probe", name="Wristband", price_cents=2000,
|
||||
granted_token_type=Token.BAND, granted_count=1,
|
||||
)
|
||||
self.assertEqual(str(item), "Wristband")
|
||||
|
||||
def test_is_available_for_unlimited_item(self):
|
||||
"""`max_owned=None` → item is always available."""
|
||||
item = ShopItem.objects.create(
|
||||
slug="probe", name="Tithe", price_cents=100,
|
||||
granted_token_type=Token.TITHE, granted_count=1,
|
||||
)
|
||||
user = User.objects.create(email="ok@test.io")
|
||||
self.assertTrue(item.is_available_for(user))
|
||||
|
||||
def test_is_available_for_when_under_max_owned(self):
|
||||
"""`max_owned=1` + user owns 0 → available."""
|
||||
item = ShopItem.objects.create(
|
||||
slug="band", name="Wristband", price_cents=2000,
|
||||
granted_token_type=Token.BAND, granted_count=1, max_owned=1,
|
||||
)
|
||||
user = User.objects.create(email="ok@test.io")
|
||||
self.assertTrue(item.is_available_for(user))
|
||||
|
||||
def test_is_not_available_for_at_max_owned(self):
|
||||
"""`max_owned=1` + user already owns 1 BAND → not available."""
|
||||
item = ShopItem.objects.create(
|
||||
slug="band", name="Wristband", price_cents=2000,
|
||||
granted_token_type=Token.BAND, granted_count=1, max_owned=1,
|
||||
)
|
||||
user = User.objects.create(email="bandowner@test.io")
|
||||
Token.objects.create(user=user, token_type=Token.BAND)
|
||||
self.assertFalse(item.is_available_for(user))
|
||||
|
||||
|
||||
class PurchaseModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="buyer@test.io")
|
||||
# Drop the auto-FREE so the FREE-token count assertions stay tight.
|
||||
self.user.tokens.all().delete()
|
||||
self.user.refresh_from_db()
|
||||
self.tithe_item = ShopItem.objects.create(
|
||||
slug="probe-tithe", name="Tithe", price_cents=100,
|
||||
granted_token_type=Token.TITHE, granted_count=1, granted_writs=144,
|
||||
)
|
||||
|
||||
def test_create_purchase_minimal(self):
|
||||
purchase = Purchase.objects.create(
|
||||
user=self.user,
|
||||
shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_test_123",
|
||||
amount_cents=100,
|
||||
granted_writs=144,
|
||||
)
|
||||
self.assertEqual(purchase.user, self.user)
|
||||
self.assertEqual(purchase.shop_item, self.tithe_item)
|
||||
self.assertEqual(purchase.status, Purchase.PENDING)
|
||||
self.assertEqual(purchase.granted_token_ids, [])
|
||||
self.assertIsNone(purchase.succeeded_at)
|
||||
|
||||
def test_stripe_payment_intent_id_is_unique(self):
|
||||
Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_dup", amount_cents=100, granted_writs=144,
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_dup",
|
||||
amount_cents=100, granted_writs=144,
|
||||
)
|
||||
|
||||
def test_fulfill_mints_tokens(self):
|
||||
purchase = Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_fulfill_1",
|
||||
amount_cents=100, granted_writs=144,
|
||||
)
|
||||
purchase.fulfill()
|
||||
self.assertEqual(
|
||||
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
|
||||
)
|
||||
|
||||
def test_fulfill_grants_writs(self):
|
||||
purchase = Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_fulfill_2",
|
||||
amount_cents=100, granted_writs=144,
|
||||
)
|
||||
before = self.user.wallet.writs
|
||||
purchase.fulfill()
|
||||
self.user.wallet.refresh_from_db()
|
||||
self.assertEqual(self.user.wallet.writs, before + 144)
|
||||
|
||||
def test_fulfill_marks_succeeded(self):
|
||||
purchase = Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_fulfill_3",
|
||||
amount_cents=100, granted_writs=144,
|
||||
)
|
||||
purchase.fulfill()
|
||||
purchase.refresh_from_db()
|
||||
self.assertEqual(purchase.status, Purchase.SUCCEEDED)
|
||||
self.assertIsNotNone(purchase.succeeded_at)
|
||||
|
||||
def test_fulfill_records_granted_token_ids(self):
|
||||
purchase = Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_fulfill_4",
|
||||
amount_cents=100, granted_writs=144,
|
||||
)
|
||||
purchase.fulfill()
|
||||
purchase.refresh_from_db()
|
||||
tithe = self.user.tokens.filter(token_type=Token.TITHE).first()
|
||||
self.assertIn(tithe.pk, purchase.granted_token_ids)
|
||||
|
||||
def test_fulfill_is_idempotent(self):
|
||||
"""Webhook + sync-confirm race could fire `fulfill()` twice — second
|
||||
call must be a no-op (no double-mint, no double-writs)."""
|
||||
purchase = Purchase.objects.create(
|
||||
user=self.user, shop_item=self.tithe_item,
|
||||
stripe_payment_intent_id="pi_idem",
|
||||
amount_cents=100, granted_writs=144,
|
||||
)
|
||||
purchase.fulfill()
|
||||
first_writs = self.user.wallet.refresh_from_db() or self.user.wallet.writs
|
||||
purchase.fulfill()
|
||||
self.user.wallet.refresh_from_db()
|
||||
self.assertEqual(self.user.wallet.writs, first_writs)
|
||||
self.assertEqual(
|
||||
self.user.tokens.filter(token_type=Token.TITHE).count(), 1
|
||||
)
|
||||
|
||||
def test_fulfill_creates_multiple_tokens_for_bundle(self):
|
||||
bundle = ShopItem.objects.create(
|
||||
slug="probe-bundle", name="Tithe ×5", price_cents=400,
|
||||
granted_token_type=Token.TITHE, granted_count=5, granted_writs=750,
|
||||
)
|
||||
purchase = Purchase.objects.create(
|
||||
user=self.user, shop_item=bundle,
|
||||
stripe_payment_intent_id="pi_bundle",
|
||||
amount_cents=400, granted_writs=750,
|
||||
)
|
||||
purchase.fulfill()
|
||||
self.assertEqual(
|
||||
self.user.tokens.filter(token_type=Token.TITHE).count(), 5
|
||||
)
|
||||
|
||||
|
||||
class SeededShopCatalogTest(TestCase):
|
||||
"""Migration `lyric/0008_seed_shop_items` seeds the 3 starting items.
|
||||
Pinned here so a future migration that touches the table can't quietly
|
||||
drop / rename / re-price them without a deliberate update to these
|
||||
assertions."""
|
||||
|
||||
def test_tithe_one_item_present(self):
|
||||
item = ShopItem.objects.get(slug="tithe-1")
|
||||
self.assertEqual(item.price_cents, 100)
|
||||
self.assertEqual(item.granted_token_type, Token.TITHE)
|
||||
self.assertEqual(item.granted_count, 1)
|
||||
self.assertEqual(item.granted_writs, 144)
|
||||
self.assertIsNone(item.max_owned)
|
||||
|
||||
def test_tithe_five_bundle_item_present(self):
|
||||
item = ShopItem.objects.get(slug="tithe-5")
|
||||
self.assertEqual(item.price_cents, 400)
|
||||
self.assertEqual(item.granted_count, 5)
|
||||
self.assertEqual(item.granted_writs, 750)
|
||||
self.assertEqual(item.badge_text, "×5")
|
||||
|
||||
def test_band_item_present(self):
|
||||
item = ShopItem.objects.get(slug="band-1")
|
||||
self.assertEqual(item.price_cents, 2000)
|
||||
self.assertEqual(item.granted_token_type, Token.BAND)
|
||||
self.assertEqual(item.granted_count, 1)
|
||||
self.assertEqual(item.max_owned, 1)
|
||||
self.assertEqual(item.granted_writs, 0)
|
||||
|
||||
def test_all_three_items_active(self):
|
||||
for slug in ("tithe-1", "tithe-5", "band-1"):
|
||||
self.assertTrue(
|
||||
ShopItem.objects.get(slug=slug).active,
|
||||
msg=f"{slug} should be active",
|
||||
)
|
||||
|
||||
def test_display_order_ascends(self):
|
||||
"""Catalog display order matches the spec: tithe-1 < tithe-5 < band-1."""
|
||||
slugs = list(
|
||||
ShopItem.objects.order_by("display_order").values_list("slug", flat=True)
|
||||
)
|
||||
# filter to the seeded ones (in case future migrations add more)
|
||||
seeded = [s for s in slugs if s in ("tithe-1", "tithe-5", "band-1")]
|
||||
self.assertEqual(seeded, ["tithe-1", "tithe-5", "band-1"])
|
||||
|
||||
|
||||
class SeededWalletShopAppletTest(TestCase):
|
||||
def test_wallet_shop_applet_present(self):
|
||||
applet = Applet.objects.get(slug="wallet-shop")
|
||||
self.assertEqual(applet.context, Applet.WALLET)
|
||||
self.assertEqual(applet.grid_cols, 12)
|
||||
Reference in New Issue
Block a user