733da3419b
Django CI/CD / test (push) Successful in 3m41s
Staging deployment / deploy (push) Successful in 1m7s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Failing after 12m17s
A multi-game Purchase is now treated as an *unsplittable* bundle (one price, whole-purchase refund). Independently-refundable multi-item orders (e.g. a Steam cart) are instead recorded as N separate single-game purchases, so per-game pricing and per-game refunds work with the existing single-purchase machinery — no through-model needed. Add-purchase form (single form, single endpoint): - 1 game: unchanged. - 2+ games: a "Separate price per game" toggle appears (default off = one bundle price). On, the bundle Price hides and one price input per game appears; the view creates one single-game Purchase each from price_for_game_<id>. `price` is now optional so combined mode still validates. Split action: - A Split button on multi-game purchase rows opens a confirmation modal that replaces the bundle with one single-game purchase per game (price split evenly, needs_price_update set), then HX-Redirects to the list. New general-purpose `selection-fields` custom element renders one synced form field per selected item of a source SearchSelect (consuming the existing search-select:change contract); it knows nothing about prices, so it is reusable. Behavior in ts/elements/selection-fields.ts. Adds the bundle-vs-separate-purchases convention to CLAUDE.md, a split icon, and unit + Playwright e2e coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
109 lines
4.2 KiB
Python
109 lines
4.2 KiB
Python
from datetime import date
|
|
|
|
from django.contrib.auth.models import User
|
|
from django.test import TestCase
|
|
from django.urls import reverse
|
|
|
|
from games.models import Game, Platform, Purchase
|
|
|
|
|
|
class AddPurchasePricingTest(TestCase):
|
|
def setUp(self):
|
|
self.user = User.objects.create_superuser("u", "u@e.com", "pw")
|
|
self.client.force_login(self.user)
|
|
self.platform = Platform.objects.create(name="PC", icon="pc", group="PC")
|
|
self.game_a = Game.objects.create(name="Game A", platform=self.platform)
|
|
self.game_b = Game.objects.create(name="Game B", platform=self.platform)
|
|
|
|
def _base_data(self, **overrides):
|
|
data = {
|
|
"games": [self.game_a.id, self.game_b.id],
|
|
"platform": self.platform.id,
|
|
"date_purchased": "2025-01-01",
|
|
"price_currency": "USD",
|
|
"ownership_type": Purchase.DIGITAL,
|
|
"type": Purchase.GAME,
|
|
"name": "",
|
|
}
|
|
data.update(overrides)
|
|
return data
|
|
|
|
def test_combined_creates_single_bundle(self):
|
|
data = self._base_data(pricing_mode="combined", price="30")
|
|
response = self.client.post(reverse("games:add_purchase"), data)
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(Purchase.objects.count(), 1)
|
|
bundle = Purchase.objects.get()
|
|
self.assertEqual(bundle.num_purchases, 2)
|
|
self.assertEqual(bundle.price, 30)
|
|
|
|
def test_per_game_creates_separate_single_game_purchases(self):
|
|
data = self._base_data(
|
|
pricing_mode="per_game",
|
|
**{
|
|
f"price_for_game_{self.game_a.id}": "10",
|
|
f"price_for_game_{self.game_b.id}": "20",
|
|
},
|
|
)
|
|
response = self.client.post(reverse("games:add_purchase"), data)
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(Purchase.objects.count(), 2)
|
|
|
|
for purchase in Purchase.objects.all():
|
|
self.assertEqual(purchase.num_purchases, 1)
|
|
self.assertEqual(sorted(p.price for p in Purchase.objects.all()), [10.0, 20.0])
|
|
linked_games = [
|
|
list(p.games.values_list("id", flat=True)) for p in Purchase.objects.all()
|
|
]
|
|
self.assertTrue(all(len(games) == 1 for games in linked_games))
|
|
self.assertEqual(
|
|
{games[0] for games in linked_games},
|
|
{self.game_a.id, self.game_b.id},
|
|
)
|
|
|
|
|
|
class SplitPurchaseTest(TestCase):
|
|
def setUp(self):
|
|
self.user = User.objects.create_superuser("u", "u@e.com", "pw")
|
|
self.client.force_login(self.user)
|
|
self.platform = Platform.objects.create(name="PC", icon="pc", group="PC")
|
|
self.game_a = Game.objects.create(name="Game A", platform=self.platform)
|
|
self.game_b = Game.objects.create(name="Game B", platform=self.platform)
|
|
|
|
def _bundle(self, games, price=30.0):
|
|
bundle = Purchase.objects.create(
|
|
price=price,
|
|
price_currency="USD",
|
|
date_purchased=date(2025, 1, 1),
|
|
platform=self.platform,
|
|
ownership_type=Purchase.DIGITAL,
|
|
type=Purchase.GAME,
|
|
)
|
|
bundle.games.set(games)
|
|
return bundle
|
|
|
|
def test_split_creates_per_game_purchases_and_deletes_original(self):
|
|
bundle = self._bundle([self.game_a, self.game_b], price=30.0)
|
|
|
|
response = self.client.post(reverse("games:split_purchase", args=[bundle.id]))
|
|
|
|
self.assertEqual(response.status_code, 204)
|
|
self.assertEqual(response["HX-Redirect"], reverse("games:list_purchases"))
|
|
self.assertFalse(Purchase.objects.filter(id=bundle.id).exists())
|
|
self.assertEqual(Purchase.objects.count(), 2)
|
|
for purchase in Purchase.objects.all():
|
|
self.assertEqual(purchase.num_purchases, 1)
|
|
self.assertEqual(purchase.price, 15.0) # 30 / 2, split evenly
|
|
self.assertTrue(purchase.needs_price_update)
|
|
|
|
def test_split_is_noop_for_single_game_purchase(self):
|
|
single = self._bundle([self.game_a], price=10.0)
|
|
|
|
response = self.client.post(reverse("games:split_purchase", args=[single.id]))
|
|
|
|
self.assertEqual(response.status_code, 204)
|
|
self.assertTrue(Purchase.objects.filter(id=single.id).exists())
|
|
self.assertEqual(Purchase.objects.count(), 1)
|