events/tests/test_ticket_email.py
Padreug 72b9c540f7
Some checks failed
lint.yml / feat: per-event organizer name + reply-to on ticket emails (pull_request) Failing after 0s
feat: per-event organizer name + reply-to on ticket emails
Each organizer is different, so the sender identity of ticket emails is
now per event rather than per instance:

- `extra.organizer_name` → From display name "Organizer via <site title>"
  (site title alone when unset). The From address stays the instance
  mailbox — that is what DKIM signs — so this costs nothing in mail auth.
- `extra.reply_to_email` → Reply-To; blank falls back to the email on the
  LNbits account that owns the event wallet; no header when neither
  exists. When a reply-to exists the body says "Questions? Reply to this
  email and it reaches <organizer>", and "Organizer: <name>" is listed
  with the ticket details.

Both fields sit next to the existing per-event subject/body in the admin
dialog. The organizer's own wording (in whatever language) remains
`notification_body`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-13 18:19:33 +02:00

145 lines
4.8 KiB
Python

from datetime import datetime, timezone
from lnbits.settings import settings
from ..models import Event, Ticket
from ..services import _ticket_notification_payload, build_ticket_email
def _event(**overrides) -> Event:
data = {
"id": "evt1",
"wallet": "w",
"name": "Test Event",
"info": "",
"closing_date": "2030-01-01",
"event_start_date": "2030-01-01T16:00:00+01:00",
"event_end_date": "2030-01-01T20:00:00+01:00",
"location": "The Chateau",
"amount_tickets": 10,
"price_per_ticket": 5,
"time": datetime.now(timezone.utc),
}
data.update(overrides)
return Event(**data)
def _ticket(**overrides) -> Ticket:
now = datetime.now(timezone.utc)
data = {
"id": "tkt1",
"wallet": "w",
"event": "evt1",
"name": "Ada",
"email": "ada@example.com",
"registered": False,
"paid": True,
"time": now,
"reg_timestamp": now,
}
data.update(overrides)
return Ticket(**data)
def test_email_carries_date_message_id_and_display_name(monkeypatch):
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
msg = build_ticket_email(
"tickets@example.org", ["ada@example.com"], "Subj", "text", "<p>html</p>"
)
assert msg["Date"]
assert msg["Message-ID"].endswith("@example.org>")
assert msg["From"] == "Oyez! <tickets@example.org>"
parts = [p.get_content_type() for p in msg.get_payload()]
assert parts == ["text/plain", "text/html"]
def test_email_attaches_the_ticket_card(monkeypatch):
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
png = b"\x89PNG\r\n\x1a\n" + b"0" * 32
msg = build_ticket_email(
"tickets@example.org",
["ada@example.com"],
"Subj",
"text",
"<p>html</p>",
attachments=[("ticket-test-8auNuuaB.png", png)],
)
assert msg.get_content_type() == "multipart/mixed"
body, attachment = msg.get_payload()
assert body.get_content_type() == "multipart/alternative"
assert attachment.get_content_type() == "image/png"
assert attachment.get_filename() == "ticket-test-8auNuuaB.png"
assert attachment.get_payload(decode=True) == png
def test_payload_includes_event_details_and_qr(monkeypatch):
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
subject, text, html = _ticket_notification_payload(_ticket(), _event())
assert "Test Event" in subject
for needle in (
"Event: Test Event",
"Where: The Chateau",
"Name on ticket: Ada",
"Ticket ID: tkt1",
"at the door",
):
assert needle in text
assert "/events/api/v1/ticket-card/tkt1" in text
assert "<img" not in html and "Ticket ID: tkt1" in html
assert '<a href="https://lnbits.example/events/api/v1/ticket-card/tkt1">' in html
def test_reply_to_and_organizer_sender_name(monkeypatch):
from ..services import organizer_sender_name
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
event = _event(
extra={
"organizer_name": "Château du Faune",
"reply_to_email": "hello@faune.example",
}
)
assert organizer_sender_name(event) == "Château du Faune via Oyez!"
assert organizer_sender_name(_event()) == "Oyez!"
msg = build_ticket_email(
"tickets@example.org",
["ada@example.com"],
"Subj",
"text",
None,
reply_to="hello@faune.example",
sender_name=organizer_sender_name(event),
)
assert msg["Reply-To"] == "hello@faune.example"
# Non-ASCII display names are RFC 2047-encoded on the wire; decode to compare.
from email.header import decode_header, make_header
assert str(make_header(decode_header(msg["From"]))) == (
"Château du Faune via Oyez! <tickets@example.org>"
)
no_reply = build_ticket_email(
"tickets@example.org", ["ada@example.com"], "Subj", "text"
)
assert no_reply["Reply-To"] is None
def test_reply_hint_only_when_reply_to_exists(monkeypatch):
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
event = _event(extra={"organizer_name": "Château du Faune"})
_, text, _ = _ticket_notification_payload(
_ticket(), event, reply_to="hello@faune.example"
)
assert "Organizer: Château du Faune" in text
assert "Reply to this email and it reaches Château du Faune" in text
_, text, _ = _ticket_notification_payload(_ticket(), event, reply_to=None)
assert "Reply to this email" not in text
def test_blank_reply_to_email_is_none():
from ..models import EventExtra
assert EventExtra(reply_to_email="").reply_to_email is None
assert EventExtra(reply_to_email=" ").reply_to_email is None
assert EventExtra(reply_to_email="a@b.example").reply_to_email == "a@b.example"