libra/tests/conftest.py
Padreug 87a45ee4d5 test(harness): split-layout ledger + disable rate limiter
The test harness was never updated to the post-server-deploy#4 split ledger
layout, so libra's per-user account opens (routed to accounts/users.beancount
by fava_client._infer_target_file) 500'd as a 'non-source file' and fell back
to DB-only — breaking the balance test and contributing to settlement errors.
Make the harness ledger a faithful split (root includes accounts/chart.beancount
+ accounts/users.beancount; title stays in root so the slug still matches).

Also raise lnbits_rate_limit_no for the session: the full suite fires >200
req/min and the default limiter 429'd fixture setup intermittently (10-11
errors). The limiter is built once at app creation, so setting it in the
session settings fixture (before the app fixture) disables it suite-wide.

Net: full suite goes from 1 failed / ~10 errors to fully green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:25:27 +02:00

714 lines
26 KiB
Python

"""Libra test infrastructure.
Brings up:
- A session-scoped Fava subprocess against a temp .beancount ledger
- A session-scoped LNbits FastAPI app with Libra extension activated
- The Libra FavaClient pointed at the test Fava instance
- Function-scoped user/wallet fixtures, plus a session-scoped superuser
Run from the LNbits source root::
PYTHONPATH=. pytest lnbits/extensions/libra/tests
Requires the `fava` binary on PATH. On NixOS::
nix-shell -p python3Packages.fava --run "pytest lnbits/extensions/libra/tests"
"""
import os
import tempfile
# IMPORTANT: configure the LNbits data folder BEFORE importing anything from
# lnbits. `lnbits/db.py` constructs Database instances at module-import time
# and freezes `settings.lnbits_data_folder` at that moment — overriding it in
# a fixture later is too late to redirect the SQLite files.
_SESSION_DATA_DIR = tempfile.mkdtemp(prefix="libra-lnbits-data-")
os.environ.setdefault("LNBITS_DATA_FOLDER", _SESSION_DATA_DIR)
# Lightning-invoice tests need a non-VoidWallet backend, but switching to
# FakeWallet here causes the LifespanManager teardown to hang indefinitely
# (the Lightning subsystem's background tasks don't unwind cleanly under
# anyio's TestRunner). Keeping VoidWallet — Lightning-invoice-generation
# tests are marked `skip` until a separate LN-harness strategy lands.
import asyncio # noqa: E402
import copy # noqa: E402
import inspect # noqa: E402
import shutil # noqa: E402
import socket # noqa: E402
import subprocess # noqa: E402
import time # noqa: E402
from pathlib import Path # noqa: E402
from typing import AsyncIterator, Iterator # noqa: E402
from uuid import uuid4 # noqa: E402
import httpx
import pytest
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from lnbits.app import create_app
from lnbits.core.crud import (
create_wallet,
delete_account,
get_user,
)
from lnbits.core.models.users import UpdateSuperuserPassword
from lnbits.core.services import create_user_account
from lnbits.core.views.auth_api import first_install
from lnbits.settings import AuthMethods, EditableSettings, Settings
from lnbits.settings import settings as lnbits_settings
LEDGER_SLUG = "libra-test"
# ---------------------------------------------------------------------------
# Settings overrides
# ---------------------------------------------------------------------------
_PURE_SETTINGS = copy.deepcopy(lnbits_settings)
_PURE_SETTINGS_FIELDS = tuple(
sorted(
{
f
for f in Settings.readonly_fields()
if f != "super_user"
}
| {
name
for name in inspect.signature(EditableSettings).parameters
if not name.startswith("_")
}
)
)
def _settings_cleanup(settings: Settings) -> None:
"""Reset mutable settings to their pre-test snapshot, then re-apply
test-specific overrides on top so each test starts from the same baseline.
Mirrors the shape of lnbits/main/tests/conftest.py: restore PURE, then
set the values the tests rely on. Without this, autouse cleanup wipes
out everything the session-scoped `settings` fixture set up.
"""
for field in _PURE_SETTINGS_FIELDS:
setattr(settings, field, getattr(_PURE_SETTINGS, field))
# Test-specific overrides — these must survive cleanup between tests.
settings.auth_https_only = False
settings.lnbits_data_folder = _SESSION_DATA_DIR
settings.lnbits_admin_extensions = [] # libra is a multi-user extension, not admin-only
settings.lnbits_admin_ui = True
settings.lnbits_extensions_default_install = []
settings.lnbits_extensions_deactivate_all = False
settings.lnbits_allow_new_accounts = True
settings.lnbits_allowed_users = []
settings.auth_allowed_methods = AuthMethods.all()
settings.auth_credetials_update_threshold = 120
settings.lnbits_require_user_activation = False
settings.lnbits_user_activation_by_invitation_code = False
settings.lnbits_register_reusable_activation_code = ""
settings.lnbits_register_one_time_activation_codes = []
# Keep the rate limiter disabled across per-test settings resets (the
# limiter itself is fixed at app-creation time, but keep the value coherent).
settings.lnbits_rate_limit_no = 1_000_000
@pytest.fixture(scope="session")
def anyio_backend() -> str:
return "asyncio"
@pytest.fixture(scope="session")
def settings() -> Iterator[Settings]:
"""LNbits settings configured for the libra test session.
Mirrors lnbits/main/tests/conftest.py: do NOT pre-set super_user; the boot
sequence assigns a UUID and creates the matching account. The `super_user`
fixture reads settings.super_user after first_install completes.
The data folder was set via LNBITS_DATA_FOLDER at the top of this module
so the lnbits/db.py import-time directory creation lands in the right
place; nothing to do here except make sure it stays consistent.
"""
lnbits_settings.auth_https_only = False
lnbits_settings.lnbits_admin_extensions = ["libra"]
lnbits_settings.lnbits_data_folder = _SESSION_DATA_DIR
lnbits_settings.lnbits_admin_ui = True
lnbits_settings.lnbits_extensions_default_install = []
lnbits_settings.lnbits_extensions_deactivate_all = False
# The full suite fires >200 requests/minute; the default rate limit (200/min)
# otherwise 429s fixture setup intermittently. The limiter is built once at
# app creation from this value (lnbits/app.py register_new_ratelimiter), and
# this fixture runs before the `app` fixture, so raising it here disables it
# for the session.
lnbits_settings.lnbits_rate_limit_no = 1_000_000
yield lnbits_settings
@pytest.fixture(autouse=True)
def _per_test_settings_reset(settings: Settings) -> Iterator[None]:
_settings_cleanup(settings)
yield
_settings_cleanup(settings)
# ---------------------------------------------------------------------------
# Fava subprocess
# ---------------------------------------------------------------------------
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
MINIMAL_LEDGER = """; Test ledger for Libra extension integration tests
; Title must slugify to match LEDGER_SLUG — Fava derives the URL slug from this.
option "title" "libra-test"
option "operating_currency" "EUR"
option "operating_currency" "SATS"
option "render_commas" "TRUE"
2020-01-01 commodity EUR
2020-01-01 commodity SATS
2020-01-01 open Assets:Lightning:Balance EUR,SATS
2020-01-01 open Assets:Bitcoin:Lightning EUR,SATS
2020-01-01 open Assets:Cash EUR,SATS
2020-01-01 open Equity:Opening-Balances EUR,SATS
2020-01-01 open Income:Generic EUR,SATS
2020-01-01 open Expenses:Generic EUR,SATS
include "accounts/chart.beancount"
include "accounts/users.beancount"
"""
# Split-layout include targets, mirroring the production fava layout
# (aiolabs/server-deploy#4). libra's fava_client routes Open directives by
# account name (fava_client._infer_target_file): per-user accounts
# (:User-xxxxxxxx) to accounts/users.beancount, everything else to
# accounts/chart.beancount. Both must exist as Fava *source* files (i.e. be
# included) or /api/source writes 500 with "non-source file". The title stays
# in the root ledger above so Fava's slug still matches LEDGER_SLUG (scalar
# options don't propagate from includes — see aiolabs/server-deploy#9).
CHART_SEED = "; Admin-mutable chart of accounts (libra appends Open directives).\n"
USERS_SEED = "; Per-user account opens (libra appends at signup).\n"
@pytest.fixture(scope="session")
def fava_ledger_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Session-scoped split ledger Fava reads from: a root file that includes
accounts/chart.beancount (admin add-account target) and
accounts/users.beancount (per-user opens target)."""
ledger_dir = tmp_path_factory.mktemp("libra-ledger")
(ledger_dir / "accounts").mkdir()
(ledger_dir / "accounts" / "chart.beancount").write_text(CHART_SEED)
(ledger_dir / "accounts" / "users.beancount").write_text(USERS_SEED)
ledger = ledger_dir / f"{LEDGER_SLUG}.beancount"
ledger.write_text(MINIMAL_LEDGER)
return ledger
@pytest.fixture(scope="session")
def fava_process(fava_ledger_path: Path) -> Iterator[str]:
"""Spawn fava as a subprocess, yield its base URL, terminate on teardown."""
fava_bin = shutil.which("fava")
if not fava_bin:
pytest.skip(
"fava not found on PATH; "
"install with `pip install fava` or `nix-shell -p python3Packages.fava`"
)
port = _find_free_port()
base_url = f"http://127.0.0.1:{port}"
proc = subprocess.Popen(
[
fava_bin,
"--host", "127.0.0.1",
"--port", str(port),
str(fava_ledger_path),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env={**os.environ, "BEANCOUNT_FILE": str(fava_ledger_path)},
)
deadline = time.monotonic() + 15.0
ready = False
while time.monotonic() < deadline:
if proc.poll() is not None:
raise RuntimeError(
f"fava exited early with returncode {proc.returncode}"
)
try:
r = httpx.get(f"{base_url}/{LEDGER_SLUG}/api/changed", timeout=0.5)
if r.status_code == 200:
ready = True
break
except httpx.RequestError:
pass
time.sleep(0.1)
if not ready:
proc.terminate()
raise RuntimeError("fava did not become ready within 15s")
try:
yield base_url
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
# ---------------------------------------------------------------------------
# LNbits app + Libra extension
# ---------------------------------------------------------------------------
def _import_libra(submodule: str):
"""Import a libra submodule under whichever path the active LNbits setup uses.
LNbits resolves an extension's module name dynamically: `lnbits.extensions.<ext>`
when extensions live in the default `lnbits/extensions/` directory, or just
`<ext>` when `LNBITS_EXTENSIONS_PATH` points elsewhere. Tests should work in
both setups.
"""
import importlib
for prefix in ("lnbits.extensions.libra", "libra"):
try:
return importlib.import_module(f"{prefix}.{submodule}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(
f"libra.{submodule}: tried 'lnbits.extensions.libra.{submodule}' and "
f"'libra.{submodule}'. Is LNBITS_EXTENSIONS_PATH pointing at the libra parent dir, "
f"or is libra symlinked into lnbits/extensions/?"
)
async def _enable_libra_for_user(user_id: str) -> None:
"""Set libra to active in the user_extensions table for `user_id`.
LNbits gates every extension API path through `check_user_extension_access`,
which requires the calling user to have the extension marked active in
`user_extensions`. New accounts have no extensions enabled, so the API
rejects them with 403 until we flip the row.
"""
from lnbits.core.services.users import update_user_extensions
await update_user_extensions(user_id, ["libra"])
async def _activate_libra(fava_url: str, super_user_id: str) -> None:
"""Point libra at the test Fava instance and enable it for the superuser.
Libra is auto-discovered + auto-installed at LNbits boot via
`LNBITS_EXTENSIONS_PATH`, so its router is already mounted, migrations
already ran, and `libra_start()` already initialised a FavaClient with
the default `http://localhost:3333/libra-ledger` URL. Three things still
need doing:
1. Redirect the FavaClient at the test Fava instance.
2. Persist the override in `extension_settings` so any caller that goes
through `services.get_settings()` picks it up too.
3. Enable libra for the superuser — per-user activation isn't automatic.
"""
libra_fava_client = _import_libra("fava_client")
libra_crud = _import_libra("crud")
libra_fava_client.init_fava_client(
fava_url=fava_url,
ledger_slug=LEDGER_SLUG,
timeout=5.0,
)
await libra_crud.db.execute("DELETE FROM extension_settings")
await libra_crud.db.execute(
"""
INSERT INTO extension_settings (id, fava_url, fava_ledger_slug, fava_timeout)
VALUES (:id, :fava_url, :slug, :timeout)
""",
{
"id": uuid4().hex,
"fava_url": fava_url,
"slug": LEDGER_SLUG,
"timeout": 5.0,
},
)
await _enable_libra_for_user(super_user_id)
@pytest.fixture(scope="session")
async def app(settings: Settings, fava_process: str) -> AsyncIterator:
"""Session-scoped LNbits app with Libra activated."""
app = create_app()
# First-time startup runs all core + libra migrations (~3-5s on cold disk),
# plus libra_start() initialises the Fava client and background tasks.
# Bump the timeout well above asgi_lifespan's 10s default so a slow
# migration step or Fava startup race doesn't spuriously fail the session.
async with LifespanManager(app, startup_timeout=60, shutdown_timeout=20) as manager:
settings.first_install = True
# pragma: allowlist secret start
await first_install(
UpdateSuperuserPassword(
username="superadmin",
password="secret1234",
password_repeat="secret1234",
first_install_token=settings.first_install_token,
)
# pragma: allowlist secret end
)
await _activate_libra(
fava_url=fava_process,
super_user_id=settings.super_user,
)
yield manager.app
@pytest.fixture(scope="session")
async def client(app, settings: Settings) -> AsyncIterator[AsyncClient]:
url = f"http://{settings.host}:{settings.port}"
async with AsyncClient(transport=ASGITransport(app=app), base_url=url) as client:
yield client
# ---------------------------------------------------------------------------
# Users
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
async def super_user(app, settings: Settings):
"""The superadmin account created by first_install."""
# first_install sets settings.super_user to the actual ID it created.
user = await get_user(settings.super_user)
assert user is not None, "superadmin was not created by first_install"
return user
@pytest.fixture
async def libra_user(app):
"""A fresh non-admin user with a wallet. Function-scoped — each test gets its own.
Libra is enabled in the user_extensions table for this user so the API
doesn't 403 with "Extension 'libra' not enabled."
"""
user = await create_user_account()
wallet = await create_wallet(
user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}"
)
await _enable_libra_for_user(user.id)
yield user, wallet
# Cleanup: best-effort
try:
await delete_account(user.id)
except Exception:
pass
@pytest.fixture
async def libra_user_b(app):
"""A second fresh non-admin user, for tests that need cross-user assertions."""
user = await create_user_account()
wallet = await create_wallet(
user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}"
)
await _enable_libra_for_user(user.id)
yield user, wallet
try:
await delete_account(user.id)
except Exception:
pass
# ---------------------------------------------------------------------------
# Auth headers
# ---------------------------------------------------------------------------
async def _user_bearer(client: AsyncClient, user_id: str) -> dict:
"""Bearer headers for a non-admin user via the `/auth/usr` user-id-only flow.
Admin/super accounts are blocked from this flow (LNbits forces them to
use username+password); regular users use it freely. Required for libra
endpoints that depend on `check_user_exists` (Bearer/cookie/usr) rather
than on a wallet API key.
"""
r = await client.post("/api/v1/auth/usr", json={"usr": user_id})
client.cookies.clear()
token = r.json().get("access_token")
assert token, f"user-id login failed: {r.status_code} {r.text}"
return {
"Authorization": f"Bearer {token}",
"Content-type": "application/json",
}
async def _superadmin_bearer(client: AsyncClient) -> dict:
"""Bearer headers for the superadmin via username+password auth.
`/api/v1/auth/usr` (user-id-only auth) is rejected for admin users —
LNbits enforces username+password for accounts in `lnbits_admin_users`
or the super_user account. So super-user fixtures use the username
flow that `first_install` configured.
"""
r = await client.post(
"/api/v1/auth", json={"username": "superadmin", "password": "secret1234"}
)
client.cookies.clear()
token = r.json().get("access_token")
assert token, f"superadmin login failed: {r.status_code} {r.text}"
return {
"Authorization": f"Bearer {token}",
"Content-type": "application/json",
}
@pytest.fixture
async def super_user_bearer_headers(client: AsyncClient, super_user) -> dict:
"""Bearer headers for the few endpoints that use LNbits `check_super_user`.
The `/libra/api/v1/settings` endpoints (and other libra paths that take
`User = Depends(check_super_user)`) require a Bearer token from
username+password login. Most other libra admin endpoints use the
wallet-admin-key auth flow — use `super_user_headers` for those.
"""
return await _superadmin_bearer(client)
@pytest.fixture
async def super_user_headers(super_user, libra_wallet) -> dict:
"""Admin-key headers for libra admin endpoints that use the wallet auth flow.
Libra's `require_super_user` dependency takes a `WalletTypeInfo` via
`require_admin_key` and verifies the wallet's owner is the LNbits
super user. So we authenticate by sending the super-user-owned wallet's
admin key as `X-Api-Key`.
"""
return admin_key_headers(libra_wallet)
def invoice_key_headers(wallet) -> dict:
"""Wallet invoice-key headers (X-Api-Key) — for require_invoice_key endpoints."""
return {"X-Api-Key": wallet.inkey, "Content-type": "application/json"}
def admin_key_headers(wallet) -> dict:
"""Wallet admin-key headers (X-Api-Key) — for require_admin_key endpoints."""
return {"X-Api-Key": wallet.adminkey, "Content-type": "application/json"}
# ---------------------------------------------------------------------------
# Libra-specific session setup: wallet, accounts
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
async def libra_wallet(
app, settings: Settings, super_user, fava_process: str, client: AsyncClient,
):
"""Session-scoped: create a wallet for the super user and register it
as the libra wallet in extension_settings.
Most flows (expense, income, settle, pay-user) refuse to operate until
this is set. Session-scoped because it's a one-time setup that any test
can share.
"""
wallet = await create_wallet(user_id=super_user.id, wallet_name="libra-main")
# Configure libra_wallet_id via the settings API so the in-memory cache
# (services.update_settings) refreshes too.
#
# Critical: include fava_url + fava_ledger_slug in the body so that
# services.update_settings()'s re-init of the FavaClient doesn't reset
# us to the default `http://localhost:3333/libra-ledger`. The settings
# endpoint rewrites the global FavaClient from the body's contents on
# every call.
headers = await _superadmin_bearer(client)
r = await client.put(
"/libra/api/v1/settings",
headers=headers,
json={
"libra_wallet_id": wallet.id,
"fava_url": fava_process,
"fava_ledger_slug": LEDGER_SLUG,
},
)
assert r.status_code == 200, f"libra_wallet setup failed: {r.status_code} {r.text}"
return wallet
@pytest.fixture(scope="session")
async def standard_accounts(app, super_user, libra_wallet, client: AsyncClient):
"""Session-scoped: create a small set of accounts used across tests.
Returns a dict of {short_name: account_dict}. Each account has at least
`id` and `name` keys.
"""
# `/accounts` POST is gated by `require_super_user` (libra-level, wallet
# admin-key flow), so we authenticate with the super-user's wallet key.
headers = admin_key_headers(libra_wallet)
async def _list_lookup(name: str) -> dict | None:
r = await client.get("/libra/api/v1/accounts", headers=headers)
if r.status_code != 200:
return None
for a in r.json():
if a.get("name") == name:
return a
return None
async def _create(name: str, account_type: str) -> dict:
# Get-then-create. Some accounts (Assets:Cash, etc.) are auto-synced
# into libra's DB from the Beancount Open directives by the account-sync
# background task. Posting a duplicate raises IntegrityError → 500;
# checking first avoids the race and the noisy error log.
existing = await _list_lookup(name)
if existing:
return existing
r = await client.post(
"/libra/api/v1/accounts",
headers=headers,
json={"name": name, "account_type": account_type},
)
if r.status_code == 201:
return r.json()
# Lost the race between our GET and POST — sync ran in between.
existing = await _list_lookup(name)
if existing:
return existing
raise AssertionError(f"create account {name}: {r.status_code} {r.text}")
return {
"expense_food": await _create("Expenses:Test:Food", "expense"),
"expense_supplies": await _create("Expenses:Test:Supplies", "expense"),
"revenue_rent": await _create("Income:Test:Rent", "revenue"),
"revenue_fees": await _create("Income:Test:Fees", "revenue"),
# Cash for revenue/settlement payment-method tests. Already declared
# as an Open directive in the Beancount file (see MINIMAL_LEDGER),
# but needs a libra-DB row too because the revenue endpoint validates
# payment-method-account via libra's local lookup.
"assets_cash": await _create("Assets:Cash", "asset"),
# Lightning balance account — the manual-payment-request approve
# endpoint posts the payment leg against this. Open directive lives
# in MINIMAL_LEDGER's Assets:Lightning:Balance, but the API code
# looks up `Assets:Bitcoin:Lightning` specifically.
"assets_lightning": await _create("Assets:Bitcoin:Lightning", "asset"),
}
# ---------------------------------------------------------------------------
# Configured user — wallet set + can submit expenses to the standard accounts
# ---------------------------------------------------------------------------
async def _grant_account_permissions(
client: AsyncClient,
libra_wallet,
user_id: str,
grants: list[tuple[str, str]],
) -> None:
"""Grant a list of (account_id, permission_type) pairs to a user.
Existing perms come back as 409; that's idempotent for fixture re-runs.
"""
headers = admin_key_headers(libra_wallet)
for account_id, permission_type in grants:
r = await client.post(
"/libra/api/v1/admin/permissions",
headers=headers,
json={
"user_id": user_id,
"account_id": account_id,
"permission_type": permission_type,
},
)
# 201 created; 409 if it already existed (idempotent).
assert r.status_code in (200, 201, 409), (
f"grant permission failed: {r.status_code} {r.text}"
)
@pytest.fixture
async def configured_user(
app, super_user, libra_wallet, standard_accounts, client: AsyncClient,
):
"""Function-scoped: fresh user with a wallet, configured for libra,
permitted to submit expenses to the standard test accounts.
Yields (user, wallet) ready to make any user-facing API call.
"""
user = await create_user_account()
wallet = await create_wallet(
user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}"
)
await _enable_libra_for_user(user.id)
# User registers their own wallet with libra. The endpoint uses
# `check_user_exists` which accepts either a Bearer access token OR
# a `?usr=<id>` query param — we use the query param to avoid the
# cookie-state interleaving that bites when two configured_user
# fixtures stack in the same test.
r = await client.put(
f"/libra/api/v1/user/wallet?usr={user.id}",
json={"user_wallet_id": wallet.id},
)
assert r.status_code == 200, f"user wallet setup failed: {r.status_code} {r.text}"
# Grant submit_expense on every expense account, submit_income on every
# revenue account, so tests can hit either user-side entry endpoint.
grants = [
(a["id"], "submit_expense")
for k, a in standard_accounts.items() if k.startswith("expense_")
] + [
(a["id"], "submit_income")
for k, a in standard_accounts.items() if k.startswith("revenue_")
]
await _grant_account_permissions(client, libra_wallet, user.id, grants)
yield user, wallet
try:
await delete_account(user.id)
except Exception:
pass
@pytest.fixture
async def configured_user_b(
app, super_user, libra_wallet, standard_accounts, client: AsyncClient,
):
"""A second configured user for cross-user tests."""
user = await create_user_account()
wallet = await create_wallet(
user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}"
)
await _enable_libra_for_user(user.id)
r = await client.put(
f"/libra/api/v1/user/wallet?usr={user.id}",
json={"user_wallet_id": wallet.id},
)
assert r.status_code == 200, f"user wallet setup failed: {r.status_code} {r.text}"
grants = [
(a["id"], "submit_expense")
for k, a in standard_accounts.items() if k.startswith("expense_")
] + [
(a["id"], "submit_income")
for k, a in standard_accounts.items() if k.startswith("revenue_")
]
await _grant_account_permissions(client, libra_wallet, user.id, grants)
yield user, wallet
try:
await delete_account(user.id)
except Exception:
pass