Brings in ticket waves (per-wave price/currency/stock/fiat), the paginated ticket endpoint, the organiser ticket-image template, and the SatsPay on-chain surface. Refs #33. Resolutions that were not mechanical, and why: - set_ticket_paid debits the wave named on the ticket, keeping upstream's `> 0` guards; ours decremented unconditionally and could go negative. The purchase and free-ticket paths now stamp ticket_wave_id / ticket_wave_title, without which every sale would debit the primary wave. - Pricing moved onto the selected wave (basket_totals takes it as a required argument, the promo-validate endpoint resolves the same wave through the shared _resolve_ticket_wave). event.price_per_ticket is a roll-up of the PRIMARY wave since sync_event_ticket_waves, so pricing off the event quoted and charged the first wave's price to buyers who picked a later one. Regression test added. - Kept npub support in two places upstream removed it: the purchase endpoint's normalize_public_key path and the notification dispatcher. Upstream's replacement rejects with "Only NIP-05 Nostr identifiers are supported", which is false for this fork. The purchase-side rejection had merged in outside any conflict marker. - _ticket_image_url existed on both sides as two unrelated features. Ours (always-attached rendered QR card) is now _ticket_card_url; upstream's (organiser template, opt-in per wave) keeps the name. Both are wired into the mail, and the /qr/{ticket_id} endpoint — also duplicated on both sides, on the same route — is merged into one handler rather than registered twice, where the second copy would have been unreachable. - models._parse_date now accepts a full ISO datetime. Upstream's date-only strptime raised ValueError on any event whose closing_date carries a time, which create_event produces by defaulting it from event_end_date — it would have 500'd the purchase path, the public event gate and the promo preview. Reproduced before fixing. - Dropped upstream's inline make_qr_png (we import a superset from .qr) and its duplicate paymentMethodOptions in display.js, which re-derived payment options from per-method booleans and offered an on-chain option the backend rejects; the submit gate now matches the template's condition. - Restored imports the merge silently dropped with upstream's npub removal (normalize_public_key, normalize_private_key, DEFAULT_NOSTR_RELAYS). Event create/update stays ours: upstream's combined endpoint would have replaced the approval workflow and the explicit field allowlist that keeps `status` out of the request body. mypy error set is unchanged from HEAD; ruff, black and the 97 tests pass.
218 lines
6.6 KiB
Python
218 lines
6.6 KiB
Python
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from ..models import (
|
|
Event,
|
|
EventExtra,
|
|
PromoCode,
|
|
PublicEvent,
|
|
Ticket,
|
|
TicketWave,
|
|
ensure_ticket_waves,
|
|
)
|
|
from ..promo import basket_totals, normalize_code, promo_usage, remaining_uses
|
|
|
|
|
|
def _event(currency="sat", price=1000.0, codes=None) -> Event:
|
|
return Event(
|
|
id="evt",
|
|
wallet="w",
|
|
name="Test",
|
|
info="",
|
|
closing_date="2030-01-01",
|
|
event_start_date="2030-01-01",
|
|
event_end_date="2030-01-02",
|
|
currency=currency,
|
|
price_per_ticket=price,
|
|
amount_tickets=10,
|
|
time=datetime.now(timezone.utc),
|
|
extra=EventExtra(promo_codes=codes or []),
|
|
)
|
|
|
|
|
|
def _wave(event: Event) -> TicketWave:
|
|
"""Primary wave synthesized from the event's own price/currency.
|
|
|
|
These tests exercise promo arithmetic rather than wave selection, so
|
|
pricing against the primary wave keeps their original meaning.
|
|
"""
|
|
return ensure_ticket_waves(event)[0]
|
|
|
|
|
|
def _ticket(code, paid=True) -> Ticket:
|
|
now = datetime.now(timezone.utc)
|
|
return Ticket(
|
|
id=f"t-{code}-{paid}",
|
|
wallet="w",
|
|
event="evt",
|
|
registered=False,
|
|
paid=paid,
|
|
time=now,
|
|
reg_timestamp=now,
|
|
extra={"applied_promo_code": code},
|
|
)
|
|
|
|
|
|
# --- model -----------------------------------------------------------------
|
|
|
|
|
|
def test_code_is_stripped_and_uppercased():
|
|
assert PromoCode(code=" half ", discount_percent=50).code == "HALF"
|
|
|
|
|
|
def test_empty_code_is_rejected():
|
|
with pytest.raises(ValidationError):
|
|
PromoCode(code=" ", discount_percent=10)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw,expected", [(None, None), ("", None), (0, None), ("0", None), (3, 3), ("7", 7)]
|
|
)
|
|
def test_max_uses_normalisation(raw, expected):
|
|
assert PromoCode(code="X", max_uses=raw).max_uses == expected
|
|
|
|
|
|
def test_max_uses_below_one_rejected():
|
|
with pytest.raises(ValidationError):
|
|
PromoCode(code="X", max_uses=-1)
|
|
|
|
|
|
def test_discount_bounds():
|
|
with pytest.raises(ValidationError):
|
|
PromoCode(code="X", discount_percent=101)
|
|
|
|
|
|
def test_public_event_projection_drops_promo_codes():
|
|
event = _event(codes=[PromoCode(code="SECRET", discount_percent=100)])
|
|
public = PublicEvent.parse_obj(event.dict()).dict()
|
|
assert "promo_codes" not in public["extra"]
|
|
assert public["extra"]["payment_methods"] == []
|
|
# the full model keeps them
|
|
assert Event.parse_obj(event.dict()).extra.promo_codes[0].code == "SECRET"
|
|
|
|
|
|
# --- helpers ---------------------------------------------------------------
|
|
|
|
|
|
def test_normalize_code():
|
|
assert normalize_code(" save20 ") == "SAVE20"
|
|
assert normalize_code("") is None
|
|
assert normalize_code(None) is None
|
|
|
|
|
|
def test_promo_usage_counts_paid_rows_only_per_ticket():
|
|
usage = promo_usage(
|
|
[_ticket("HALF"), _ticket("HALF"), _ticket("HALF", paid=False), _ticket(None)]
|
|
)
|
|
assert usage == {"HALF": 2}
|
|
|
|
|
|
def test_remaining_uses():
|
|
assert remaining_uses(PromoCode(code="X"), 5) is None
|
|
assert remaining_uses(PromoCode(code="X", max_uses=3), 1) == 2
|
|
assert remaining_uses(PromoCode(code="X", max_uses=3), 9) == 0
|
|
|
|
|
|
# --- basket_totals -----------------------------------------------------------
|
|
|
|
|
|
def test_sat_totals_round_to_whole_sats():
|
|
event = _event(price=333, codes=[PromoCode(code="OFF15", discount_percent=15)])
|
|
totals = basket_totals(event, ["off15"], 1, {}, _wave(event))
|
|
assert (totals.subtotal, totals.total, totals.discount) == (333, 283, 50)
|
|
assert totals.currency == "sat"
|
|
assert totals.discounts_applied[0].dict() == {
|
|
"code": "OFF15",
|
|
"discount_percent": 15,
|
|
"discount_fixed": None,
|
|
"amount_saved": 50,
|
|
}
|
|
|
|
|
|
def test_fiat_totals_round_to_cents_and_scale_by_quantity():
|
|
event = _event(
|
|
currency="EUR",
|
|
price=19.99,
|
|
codes=[PromoCode(code="THIRD", discount_percent=33)],
|
|
)
|
|
totals = basket_totals(event, ["THIRD"], 3, {}, _wave(event))
|
|
assert totals.subtotal == 59.97
|
|
assert totals.total == 40.18
|
|
assert totals.discount == 19.79
|
|
assert totals.discount + totals.total == totals.subtotal
|
|
|
|
|
|
def test_first_applicable_code_wins():
|
|
event = _event(
|
|
codes=[
|
|
PromoCode(code="A", discount_percent=10),
|
|
PromoCode(code="B", discount_percent=50),
|
|
]
|
|
)
|
|
assert (
|
|
basket_totals(event, ["NOPE", "B", "A"], 1, {}, _wave(event))
|
|
.discounts_applied[0]
|
|
.code
|
|
== "B"
|
|
)
|
|
|
|
|
|
def test_inactive_unknown_zero_and_exhausted_codes_are_absent():
|
|
event = _event(
|
|
codes=[
|
|
PromoCode(code="OLD", discount_percent=20, active=False),
|
|
PromoCode(code="ZERO", discount_percent=0),
|
|
PromoCode(code="TWO", discount_percent=50, max_uses=2),
|
|
]
|
|
)
|
|
for codes, usage, qty in (
|
|
(["OLD"], {}, 1),
|
|
(["ZERO"], {}, 1),
|
|
(["NOPE"], {}, 1),
|
|
(["TWO"], {"TWO": 2}, 1),
|
|
(["TWO"], {"TWO": 1}, 2), # not enough left for the whole quantity
|
|
):
|
|
totals = basket_totals(event, codes, qty, usage, _wave(event))
|
|
assert totals.discounts_applied == []
|
|
assert totals.total == totals.subtotal and totals.discount == 0
|
|
|
|
|
|
def test_unlimited_and_partially_used_codes_apply():
|
|
event = _event(
|
|
codes=[
|
|
PromoCode(code="TWO", discount_percent=50, max_uses=2),
|
|
PromoCode(code="INF", discount_percent=10),
|
|
]
|
|
)
|
|
assert basket_totals(event, ["TWO"], 1, {"TWO": 1}, _wave(event)).total == 500
|
|
assert basket_totals(event, ["INF"], 10, {"INF": 999}, _wave(event)).total == 9000
|
|
|
|
|
|
def test_full_discount_prices_to_zero():
|
|
event = _event(codes=[PromoCode(code="FREE", discount_percent=100)])
|
|
assert basket_totals(event, ["FREE"], 2, {}, _wave(event)).total == 0
|
|
|
|
|
|
def test_price_comes_from_the_selected_wave_not_the_event():
|
|
"""Regression guard for the v1.6.8 merge.
|
|
|
|
`sync_event_ticket_waves` makes `event.price_per_ticket` a roll-up of the
|
|
PRIMARY wave, so pricing off the event charged every buyer the first
|
|
wave's price no matter which wave they picked.
|
|
"""
|
|
event = _event(price=1000.0, codes=[PromoCode(code="HALF", discount_percent=50)])
|
|
late = TicketWave(
|
|
id="late",
|
|
title="Late",
|
|
opening_date="2030-01-01",
|
|
closing_date="2030-02-01",
|
|
currency="sat",
|
|
price_per_ticket=2500.0,
|
|
amount_tickets=10,
|
|
)
|
|
|
|
assert basket_totals(event, [], 2, {}, _wave(event)).total == 2000
|
|
assert basket_totals(event, [], 2, {}, late).total == 5000
|
|
assert basket_totals(event, ["HALF"], 2, {}, late).total == 2500
|