Three-tier functional test infrastructure: - CLI tests via Typer CliRunner (timmy, timmy-serve, self-tdd) - Dashboard integration tests with real TestClient, real SQLite, real coordinator (no patch/mock — Ollama offline = graceful degradation) - Docker compose container-level tests (gated by FUNCTIONAL_DOCKER=1) - End-to-end L402 payment flow with real mock-lightning backend 42 new tests (8 Docker tests skipped without FUNCTIONAL_DOCKER=1). All 849 tests pass. https://claude.ai/code/session_01WU4h3cQQiouMwmgYmAgkMM
107 lines
4.0 KiB
Python
107 lines
4.0 KiB
Python
"""Functional test for the full L402 payment flow.
|
|
|
|
Uses the real mock-lightning backend (LIGHTNING_BACKEND=mock) — no patching.
|
|
This exercises the entire payment lifecycle a real client would go through:
|
|
|
|
1. Hit protected endpoint → get 402 + invoice + macaroon
|
|
2. "Pay" the invoice (settle via mock backend)
|
|
3. Present macaroon:preimage → get access
|
|
"""
|
|
|
|
import pytest
|
|
|
|
|
|
class TestL402PaymentFlow:
|
|
"""End-to-end L402 payment lifecycle."""
|
|
|
|
def test_unprotected_endpoints_work(self, serve_client):
|
|
"""Status and health don't require payment."""
|
|
resp = serve_client.get("/serve/status")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["status"] == "active"
|
|
assert data["price_sats"] == 100
|
|
|
|
health = serve_client.get("/health")
|
|
assert health.status_code == 200
|
|
|
|
def test_chat_without_payment_returns_402(self, serve_client):
|
|
"""Hitting /serve/chat without an L402 token gives 402."""
|
|
resp = serve_client.post(
|
|
"/serve/chat",
|
|
json={"message": "hello"},
|
|
)
|
|
assert resp.status_code == 402
|
|
data = resp.json()
|
|
assert data["error"] == "Payment Required"
|
|
assert data["code"] == "L402"
|
|
assert "macaroon" in data
|
|
assert "invoice" in data
|
|
assert "payment_hash" in data
|
|
assert data["amount_sats"] == 100
|
|
|
|
# WWW-Authenticate header should be present
|
|
assert "WWW-Authenticate" in resp.headers
|
|
assert "L402" in resp.headers["WWW-Authenticate"]
|
|
|
|
def test_chat_with_garbage_token_returns_402(self, serve_client):
|
|
resp = serve_client.post(
|
|
"/serve/chat",
|
|
json={"message": "hello"},
|
|
headers={"Authorization": "L402 garbage:token"},
|
|
)
|
|
assert resp.status_code == 402
|
|
|
|
def test_full_payment_lifecycle(self, serve_client):
|
|
"""Complete flow: get challenge → pay → access."""
|
|
from timmy_serve.payment_handler import payment_handler
|
|
|
|
# Step 1: Hit protected endpoint, get 402 challenge
|
|
challenge_resp = serve_client.post(
|
|
"/serve/chat",
|
|
json={"message": "hello"},
|
|
)
|
|
assert challenge_resp.status_code == 402
|
|
challenge = challenge_resp.json()
|
|
macaroon = challenge["macaroon"]
|
|
payment_hash = challenge["payment_hash"]
|
|
|
|
# Step 2: "Pay" the invoice via the mock backend's auto-settle
|
|
# The mock backend settles invoices when you provide the correct preimage.
|
|
# Get the preimage from the mock backend's internal state.
|
|
invoice = payment_handler.get_invoice(payment_hash)
|
|
assert invoice is not None
|
|
preimage = invoice.preimage # mock backend exposes this
|
|
|
|
# Step 3: Present macaroon:preimage to access the endpoint
|
|
resp = serve_client.post(
|
|
"/serve/chat",
|
|
json={"message": "hello after paying"},
|
|
headers={"Authorization": f"L402 {macaroon}:{preimage}"},
|
|
)
|
|
# The chat will fail because Ollama isn't running, but the
|
|
# L402 middleware should let us through (status != 402).
|
|
# We accept 200 (success) or 500 (Ollama offline) — NOT 402.
|
|
assert resp.status_code != 402
|
|
|
|
def test_create_invoice_via_api(self, serve_client):
|
|
"""POST /serve/invoice creates a real invoice."""
|
|
resp = serve_client.post(
|
|
"/serve/invoice",
|
|
json={"amount_sats": 500, "memo": "premium access"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["amount_sats"] == 500
|
|
assert data["payment_hash"]
|
|
assert data["payment_request"]
|
|
|
|
def test_status_reflects_invoices(self, serve_client):
|
|
"""Creating invoices should be reflected in /serve/status."""
|
|
serve_client.post("/serve/invoice", json={"amount_sats": 100, "memo": "test"})
|
|
serve_client.post("/serve/invoice", json={"amount_sats": 200, "memo": "test2"})
|
|
|
|
resp = serve_client.get("/serve/status")
|
|
data = resp.json()
|
|
assert data["total_invoices"] >= 2
|