"""End-to-end webhook tests — secret check, audit log, mint, email, idempotency.""" from __future__ import annotations import os from unittest.mock import patch import pytest from app.email import LicenseEmail from app.models import GumroadEvent, License @pytest.fixture def with_gumroad_secret(monkeypatch): monkeypatch.setenv("GUMROAD_WEBHOOK_SECRET", "test-gumroad-secret") from app.config import get_settings get_settings.cache_clear() yield get_settings.cache_clear() def _form(**overrides) -> dict: base = { "sale_id": "GUM-2001", "email": "jane@example.com", "full_name": "Jane Doe", "product_id": "datatools-core", "price": "9900", "currency": "usd", "offer_code": "", "test": "false", } base.update(overrides) return base def test_webhook_rejects_missing_secret(client, with_gumroad_secret): r = client.post("/webhooks/gumroad", data=_form()) assert r.status_code == 404 def test_webhook_rejects_wrong_secret(client, with_gumroad_secret): r = client.post("/webhooks/gumroad?secret=wrong", data=_form()) assert r.status_code == 404 def test_webhook_mints_and_sends_email(client, with_gumroad_secret, db_session): captured: list[LicenseEmail] = [] class CapturingEmail: def send_license(self, msg): captured.append(msg) return "captured" with patch("app.routes.webhooks.get_email_service", lambda: CapturingEmail()): r = client.post( "/webhooks/gumroad?secret=test-gumroad-secret", data=_form(), ) assert r.status_code == 200 body = r.json() assert body["status"] == "ok" assert body["license_key"].startswith("DT1-CORE-") # DB row landed. row = db_session.query(License).filter_by(source_order_id="GUM-2001").one() assert row.source == "gumroad" assert row.email == "jane@example.com" assert row.tier == "core" assert row.blob.startswith("DTLIC2:") # Audit row processed. event = db_session.query(GumroadEvent).filter_by(order_id="GUM-2001").one() assert event.processed is True assert event.error is None # Email captured. assert len(captured) == 1 assert captured[0].to_email == "jane@example.com" assert captured[0].blob == row.blob def test_webhook_idempotent_on_duplicate_sale_id(client, with_gumroad_secret, db_session): """Gumroad retries on transient failures. Same sale_id must produce one license, not two.""" with patch("app.routes.webhooks.get_email_service", lambda: _NullEmail()): client.post("/webhooks/gumroad?secret=test-gumroad-secret", data=_form()) client.post("/webhooks/gumroad?secret=test-gumroad-secret", data=_form()) rows = db_session.query(License).filter_by(source_order_id="GUM-2001").all() assert len(rows) == 1 # But both webhook deliveries should be in the audit log. events = db_session.query(GumroadEvent).filter_by(order_id="GUM-2001").all() assert len(events) == 2 def test_webhook_unmapped_product_audits_and_returns_200(client, with_gumroad_secret, db_session): """An unknown product_id must NOT crash and must NOT trigger a retry storm. Audit row gets the error reason.""" with patch("app.routes.webhooks.get_email_service", lambda: _NullEmail()): r = client.post( "/webhooks/gumroad?secret=test-gumroad-secret", data=_form(product_id="not-a-real-sku", sale_id="GUM-666"), ) assert r.status_code == 200 assert r.json()["status"] == "logged-no-mint" # No license, but audit row with error. assert db_session.query(License).filter_by(source_order_id="GUM-666").count() == 0 event = db_session.query(GumroadEvent).filter_by(order_id="GUM-666").one() assert event.processed is False assert "no entry in config/products.yaml" in (event.error or "") def test_webhook_email_failure_keeps_license(client, with_gumroad_secret, db_session): """If Postmark hiccups, the buyer's license is still minted and persists in the DB. They can be served from the renewal portal (PR 3) or a manual resend.""" from app.email import EmailDeliveryError class FailingEmail: def send_license(self, msg): raise EmailDeliveryError("Postmark 503") with patch("app.routes.webhooks.get_email_service", lambda: FailingEmail()): r = client.post( "/webhooks/gumroad?secret=test-gumroad-secret", data=_form(sale_id="GUM-3001"), ) assert r.status_code == 200 assert r.json()["status"] == "minted-email-failed" assert db_session.query(License).filter_by(source_order_id="GUM-3001").count() == 1 event = db_session.query(GumroadEvent).filter_by(order_id="GUM-3001").one() assert event.processed is True # we count it processed so Gumroad doesn't retry assert "email error" in (event.error or "") class _NullEmail: def send_license(self, msg): return "null"