Model refundable orders as separate purchases; add split action
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
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>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"""Browser tests for the purchase pricing UX and the split action.
|
||||
|
||||
- A synthetic page isolates the general ``selection-fields`` element (no API,
|
||||
deterministic option values), mirroring ``test_search_select_e2e.py``.
|
||||
- The real-app tests drive the actual add-purchase form and the split modal
|
||||
against pytest-django's ``live_server``.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from django.urls import path, reverse
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
from common.components import SearchSelect, SelectionFields
|
||||
from games.models import Game, Platform, Purchase
|
||||
|
||||
|
||||
def selection_fields_view(request):
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script type="module" src="/static/js/search_select.js"></script>
|
||||
<script type="module" src="/static/js/dist/elements/selection-fields.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div style="padding: 50px;">
|
||||
{
|
||||
SearchSelect(
|
||||
name="games",
|
||||
selected=[],
|
||||
options=[
|
||||
{"value": "7", "label": "Game A", "data": {}},
|
||||
{"value": "8", "label": "Game B", "data": {}},
|
||||
],
|
||||
multi_select=True,
|
||||
)
|
||||
}
|
||||
{
|
||||
SelectionFields(
|
||||
source="games",
|
||||
name_prefix="price_for_game_",
|
||||
field_type="number",
|
||||
min_items=2,
|
||||
active=True,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HttpResponse(html)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("sf-test/", selection_fields_view),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_purchase_e2e")
|
||||
def test_selection_fields_syncs_with_source(live_server, page: Page):
|
||||
page.goto(live_server.url + "/sf-test/")
|
||||
|
||||
games = page.locator('[data-search-select][data-name="games"]')
|
||||
rows = page.locator("selection-fields [data-selection-fields-rows] input")
|
||||
|
||||
# Below min_items (2): nothing rendered.
|
||||
expect(rows).to_have_count(0)
|
||||
|
||||
games.locator("[data-search-select-search]").click()
|
||||
games.locator('[data-search-select-option][data-value="7"]').click()
|
||||
expect(rows).to_have_count(0) # only one selected, still below min_items
|
||||
|
||||
games.locator("[data-search-select-search]").click()
|
||||
games.locator('[data-search-select-option][data-value="8"]').click()
|
||||
expect(rows).to_have_count(2)
|
||||
|
||||
# One input per item, named by the prefix + item id.
|
||||
expect(
|
||||
page.locator('selection-fields input[name="price_for_game_7"]')
|
||||
).to_have_count(1)
|
||||
expect(
|
||||
page.locator('selection-fields input[name="price_for_game_8"]')
|
||||
).to_have_count(1)
|
||||
|
||||
# Typed values survive removing and re-adding another item.
|
||||
page.locator('selection-fields input[name="price_for_game_7"]').fill("12")
|
||||
games.locator('[data-pill][data-value="8"] [data-pill-remove]').click()
|
||||
expect(rows).to_have_count(0)
|
||||
games.locator("[data-search-select-search]").click()
|
||||
games.locator('[data-search-select-option][data-value="8"]').click()
|
||||
expect(rows).to_have_count(2)
|
||||
expect(
|
||||
page.locator('selection-fields input[name="price_for_game_7"]')
|
||||
).to_have_value("12")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
|
||||
django_user_model.objects.create_user(username="tester", password="secret123")
|
||||
page.goto(f"{live_server.url}{reverse('login')}")
|
||||
page.fill('input[name="username"]', "tester")
|
||||
page.fill('input[name="password"]', "secret123")
|
||||
page.click('input[type="submit"]')
|
||||
page.wait_for_url(f"{live_server.url}/tracker**")
|
||||
return page
|
||||
|
||||
|
||||
def _select_two_games(page: Page) -> None:
|
||||
games = page.locator('[data-search-select][data-name="games"]')
|
||||
games.locator("[data-search-select-search]").click()
|
||||
options = games.locator("[data-search-select-option]")
|
||||
expect(options).to_have_count(2) # prefetched on focus
|
||||
options.nth(0).click()
|
||||
options.nth(1).click()
|
||||
|
||||
|
||||
def test_add_purchase_per_game_toggle_reveals_inputs(
|
||||
authenticated_page: Page, live_server
|
||||
):
|
||||
"""The combined/per-game toggle appears only at 2+ games; turning it on
|
||||
hides the bundle Price and shows one price input per selected game.
|
||||
(Server-side creation of N purchases is covered by the unit tests.)"""
|
||||
page = authenticated_page
|
||||
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
|
||||
Game.objects.create(name="Alpha Game", platform=platform)
|
||||
Game.objects.create(name="Beta Game", platform=platform)
|
||||
|
||||
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
|
||||
|
||||
checkbox_row = page.locator("#separate-prices-row")
|
||||
expect(checkbox_row).to_be_hidden()
|
||||
|
||||
_select_two_games(page)
|
||||
expect(checkbox_row).to_be_visible()
|
||||
|
||||
page.locator("#id_separate_prices").check()
|
||||
expect(page.locator("#id_price")).to_be_hidden()
|
||||
per_game_inputs = page.locator(
|
||||
"selection-fields [data-selection-fields-rows] input"
|
||||
)
|
||||
expect(per_game_inputs).to_have_count(2)
|
||||
|
||||
|
||||
def test_split_purchase_action(authenticated_page: Page, live_server):
|
||||
page = authenticated_page
|
||||
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
|
||||
game_a = Game.objects.create(name="Alpha Game", platform=platform)
|
||||
game_b = Game.objects.create(name="Beta Game", platform=platform)
|
||||
bundle = Purchase.objects.create(
|
||||
price=30.0,
|
||||
price_currency="USD",
|
||||
date_purchased=date(2025, 1, 1),
|
||||
platform=platform,
|
||||
ownership_type=Purchase.DIGITAL,
|
||||
type=Purchase.GAME,
|
||||
)
|
||||
bundle.games.set([game_a, game_b])
|
||||
|
||||
page.goto(f"{live_server.url}{reverse('games:list_purchases')}")
|
||||
# Before: one bundle row.
|
||||
expect(page.locator('[id^="purchase-row-"]')).to_have_count(1)
|
||||
|
||||
page.locator('[title="Split into per-game purchases"]').click()
|
||||
modal = page.locator("#split-confirmation-modal")
|
||||
expect(modal).to_be_visible()
|
||||
modal.locator('button[type="submit"]', has_text="Split").click()
|
||||
|
||||
page.wait_for_url(f"{live_server.url}{reverse('games:list_purchases')}**")
|
||||
# After: the bundle row is gone, replaced by two per-game rows. Asserted via
|
||||
# the UI (not the ORM) to avoid live_server/SQLite write-read contention.
|
||||
expect(page.locator(f"#purchase-row-{bundle.id}")).to_have_count(0)
|
||||
expect(page.locator('[id^="purchase-row-"]')).to_have_count(2)
|
||||
Reference in New Issue
Block a user