Files
timetracker/e2e/test_purchase_e2e.py
T
lukas 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
Model refundable orders as separate purchases; add split action
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>
2026-06-19 11:36:47 +02:00

179 lines
6.3 KiB
Python

"""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)