[feat] Payment labels (#3537)

This commit is contained in:
Vlad Stan 2025-11-21 10:33:53 +02:00 committed by GitHub
parent 3ccefb70fa
commit 7c7a04da9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 930 additions and 39 deletions

View file

@ -1,12 +1,16 @@
import hashlib
from json import JSONDecodeError
from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest
import shortuuid
from pytest_mock.plugin import MockerFixture
from lnbits import bolt11
from lnbits.core.models import CreateInvoice, Payment
from lnbits.core.models.users import Account, UserExtra, UserLabel
from lnbits.core.services.users import create_user_account
from lnbits.core.views.payment_api import api_payment
from lnbits.fiat.base import FiatInvoiceResponse
from lnbits.settings import Settings
@ -789,3 +793,176 @@ async def test_api_payments_pay_lnurl(client, adminkey_headers_from):
)
assert response.status_code == 400
assert "value_error.url.scheme" in response.json()["detail"]
################################ Labels ################################
@pytest.mark.anyio
async def test_api_search_payment_labels(client):
tiny_id = shortuuid.uuid()[:8]
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"u{tiny_id}",
extra=UserExtra(
labels=[
UserLabel(name="label A", color="#FF0000"),
UserLabel(name="label B", color="#00FF00"),
]
),
)
)
assert len(user.extra.labels) == 2
adminkey = user.wallets[0].adminkey
payments_headers = {
"X-Api-Key": adminkey,
"Content-type": "application/json",
}
payment_count = 10
await _create_some_payments(payment_count, client, payments_headers)
# search payments by label A
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label A"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 2
for payment in data["data"]:
assert "label A" in payment["labels"]
# search payments by label B
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label B"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 3
for payment in data["data"]:
assert "label B" in payment["labels"]
# search payments by label C
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label C"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 5
for payment in data["data"]:
assert "label C" in payment["labels"]
# search payments by label A and B
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label A", "label B"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 6
for payment in data["data"]:
assert "label A" in payment["labels"]
assert "label B" in payment["labels"]
# search payments for random label D (no payments)
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label D"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == 0
# search payments with no label filter (all payments)
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": []},
headers=payments_headers,
)
assert response.is_success
all_payments = response.json()
assert all_payments["total"] == payment_count
no_label_a_payment = next(
(
payment
for payment in all_payments["data"]
if "label A" not in payment["labels"]
),
None,
)
assert no_label_a_payment is not None
payment_hash = no_label_a_payment["payment_hash"]
response = await client.put(
f"/api/v1/payments/{payment_hash}/labels",
headers=payments_headers,
json={"labels": ["label A"]},
)
# search payments by label A after update
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label A"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == payment_count // 2 + 1 # one more after update
for payment in data["data"]:
assert "label A" in payment["labels"]
# remove label A from all payments
for payment in all_payments["data"]:
payment_hash = payment["payment_hash"]
response = await client.put(
f"/api/v1/payments/{payment_hash}/labels",
headers=payments_headers,
json={"labels": []},
)
# search payments by label A (none should have it now)
response = await client.get(
"/api/v1/payments/paginated",
params={"labels[every]": ["label A"]},
headers=payments_headers,
)
assert response.is_success
data = response.json()
assert data["total"] == 0
async def _create_some_payments(payment_count: int, client, payments_headers):
payment_count = 10
for index in range(1, payment_count + 1):
labels = []
if index % 2 == 0:
labels.append("label A")
if index % 3 == 0:
labels.append("label B")
if index % 5 == 0:
# User does not have this label, but will be added to the payment.
labels.append("label C")
response = await client.post(
"/api/v1/payments",
headers=payments_headers,
json={
"out": False,
"amount": 1000 + index,
"memo": f"payment {index}",
"labels": labels,
},
)
assert response.is_success
data = response.json()
assert data["labels"] == labels
return payment_count

View file

@ -24,7 +24,9 @@ from lnbits.core.models.users import (
EndpointAccess,
LoginUsr,
UpdateAccessControlList,
UpdateUser,
UserAcls,
UserLabel,
)
from lnbits.core.services.users import create_user_account
from lnbits.core.views.user_api import api_users_reset_password
@ -1999,3 +2001,48 @@ async def test_api_delete_user_api_token_missing_token_id(
json=delete_token_request.dict(),
)
assert response.status_code == 200, "Does noting if token not found."
################################ Labels ################################
@pytest.mark.anyio
async def test_api_update_user_labels(http_client: AsyncClient):
tiny_id = shortuuid.uuid()[:8]
user = await create_user_account()
assert user.extra.labels == []
user.extra.labels = [
UserLabel(name="label 01", color="#FF0000"),
UserLabel(name="label 02", color="#00FF00"),
]
data = UpdateUser(user_id=user.id, username=f"u{tiny_id}", extra=user.extra)
assert data.extra
response = await http_client.put(
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
)
assert response.status_code == 200
user_data = response.json()
assert len(user_data["extra"]["labels"]) == 2
assert user_data["extra"]["labels"][0]["name"] == "label 01"
assert user_data["extra"]["labels"][0]["color"] == "#FF0000"
assert user_data["extra"]["labels"][1]["name"] == "label 02"
assert user_data["extra"]["labels"][1]["color"] == "#00FF00"
data.extra.labels = []
response = await http_client.put(
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
)
assert response.status_code == 200
user_data = response.json()
assert len(user_data["extra"]["labels"]) == 0
json_data = data.dict()
json_data["extra"] = {"labels": [{"name": "label + 01", "color": "#FF0000"}]}
response = await http_client.put(
"/api/v1/auth/update?usr=" + user.id, json=json_data
)
assert response.status_code == 400
data = response.json()
assert (
"""string does not match regex "([A-Za-z0-9 ._-]{1,100}$)""" in data["detail"]
)

View file

@ -16,6 +16,7 @@ from lnbits.core.crud import (
get_payment,
update_payment,
)
from lnbits.core.crud.users import get_user_from_account
from lnbits.core.models import Account, CreateInvoice, PaymentState, User
from lnbits.core.models.users import UpdateSuperuserPassword
from lnbits.core.services import create_user_account, update_wallet_balance
@ -114,6 +115,10 @@ async def user_alan():
@pytest.fixture(scope="session")
async def admin_user():
username = "admin"
account = await get_account_by_username(username)
if account:
return await get_user_from_account(account)
account = Account(
id=ADMIN_USER_ID,
email=f"{username}@lnbits.com",