Some checks failed
ci.yml / feat(transport): get_machine_config RPC — server-delivered machine config (#41) (pull_request) Failing after 0s
A paired ATM pulls its operator pubkey + fee config over the already-
authenticated kind-21000 transport, instead of the operator pubkey being
provisioned into the machine's .env and the fee config learned only from an
operator-signed kind-30078 broadcast. This lets a seed-only machine (blank
.env, scanned spire-seed) leave "awaiting configuration" with zero per-machine
provisioning — closing the gap surfaced by bitspire#70. Client half: bitspire#71.
The transport is already per-machine authenticated (the ATM signs with its
bunker-minted spire key == dca_machines.machine_npub), so the handler resolves
the exact machine → operator from the verified request.sender_pubkey — same
lookup as cashin_transport, no client-supplied trust — and returns only the
caller's own config. Returns {operator_pubkey, fee_config, fiat_code,
machine_npub, wallet_id, created_at}; fee_config is None until the operator has
a super-config. Reuses build_fee_payload + get_account; AUTH_ACCOUNT; registers
soft-failing (older lnbits) like the roster hook. kind-30078 push stays
dual-run for live mid-run updates.
6 tests; full suite 235 pass; black + ruff clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
117 lines
4 KiB
Python
117 lines
4 KiB
Python
"""Tests for the get_machine_config nostr-transport RPC handler
|
|
(spirekeeper#41 / bitspire#70 P1). The handler resolves the machine from the
|
|
verified request.sender_pubkey and returns operator pubkey + fee config; the
|
|
crud/account lookups are monkeypatched (no DB)."""
|
|
|
|
import asyncio
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from .. import machine_config_transport
|
|
from ..machine_config_transport import handle_get_machine_config
|
|
from ..models import Machine, SuperConfig
|
|
|
|
_NOW = datetime(2026, 7, 2, 12, 0, 0)
|
|
_LATER = datetime(2026, 7, 3, 12, 0, 0)
|
|
_ATM_HEX = "b1dbceccc6e717f9fb2283bee389080220d719b0e95cc0ab6bf709c23caae6fa"
|
|
_OP_HEX = "8eb610540021d3773606068a2077821e9233736d1b6dcd4e4f9a3de59b2eddcdc3"
|
|
|
|
|
|
def _machine(updated_at: datetime = _NOW) -> Machine:
|
|
return Machine(
|
|
id="m1",
|
|
operator_user_id="op1",
|
|
machine_npub=_ATM_HEX,
|
|
wallet_id="w1",
|
|
name="sintra",
|
|
location=None,
|
|
fiat_code="EUR",
|
|
is_active=True,
|
|
operator_cash_in_fee_fraction=0.05,
|
|
operator_cash_out_fee_fraction=0.05,
|
|
created_at=_NOW,
|
|
updated_at=updated_at,
|
|
)
|
|
|
|
|
|
def _super(updated_at: datetime = _NOW) -> SuperConfig:
|
|
return SuperConfig(
|
|
id="default",
|
|
super_cash_in_fee_fraction=0.03,
|
|
super_cash_out_fee_fraction=0.03,
|
|
super_fee_wallet_id="super-wallet",
|
|
updated_at=updated_at,
|
|
)
|
|
|
|
|
|
def _wire(monkeypatch, *, machine, pubkey: object = _OP_HEX, super_config):
|
|
async def fake_get_machine(_hex):
|
|
return machine
|
|
|
|
async def fake_get_account(_uid):
|
|
return None if pubkey is _MISSING else SimpleNamespace(pubkey=pubkey)
|
|
|
|
async def fake_get_super():
|
|
return super_config
|
|
|
|
monkeypatch.setattr(
|
|
machine_config_transport, "get_machine_by_atm_pubkey_hex", fake_get_machine
|
|
)
|
|
monkeypatch.setattr(machine_config_transport, "get_account", fake_get_account)
|
|
monkeypatch.setattr(machine_config_transport, "get_super_config", fake_get_super)
|
|
|
|
|
|
_MISSING = object() # sentinel: account with no pubkey
|
|
|
|
|
|
def _call(sender: str | None = _ATM_HEX):
|
|
req = SimpleNamespace(sender_pubkey=sender)
|
|
return asyncio.run(handle_get_machine_config(None, req))
|
|
|
|
|
|
def test_returns_operator_pubkey_and_fee_config(monkeypatch):
|
|
_wire(monkeypatch, machine=_machine(), super_config=_super())
|
|
result = _call()
|
|
assert result["operator_pubkey"] == _OP_HEX
|
|
assert result["fee_config"] is not None
|
|
assert result["fee_config"]["cash_in_fee_fraction"] == 0.08 # 0.03 + 0.05
|
|
assert result["fee_config"]["cash_out_fee_fraction"] == 0.08
|
|
assert result["fiat_code"] == "EUR"
|
|
assert result["machine_npub"] == _ATM_HEX
|
|
assert result["wallet_id"] == "w1"
|
|
|
|
|
|
def test_watermark_is_max_of_machine_and_super(monkeypatch):
|
|
# machine updated after super → watermark tracks the later one
|
|
_wire(monkeypatch, machine=_machine(updated_at=_LATER), super_config=_super())
|
|
assert _call()["created_at"] == int(_LATER.timestamp())
|
|
|
|
|
|
def test_no_super_config_still_returns_operator_pubkey_no_fees(monkeypatch):
|
|
# Delivering the operator pubkey re-enables the ATM's fees subscription even
|
|
# before the operator has a super-config; fee_config is None until then.
|
|
_wire(monkeypatch, machine=_machine(), super_config=None)
|
|
result = _call()
|
|
assert result["operator_pubkey"] == _OP_HEX
|
|
assert result["fee_config"] is None
|
|
assert result["created_at"] == int(_NOW.timestamp()) # machine.updated_at only
|
|
|
|
|
|
def test_unpaired_sender_raises(monkeypatch):
|
|
_wire(monkeypatch, machine=None, super_config=_super())
|
|
with pytest.raises(ValueError, match="not a paired machine"):
|
|
_call()
|
|
|
|
|
|
def test_operator_without_pubkey_raises(monkeypatch):
|
|
_wire(monkeypatch, machine=_machine(), pubkey=_MISSING, super_config=_super())
|
|
with pytest.raises(ValueError, match="no Nostr pubkey"):
|
|
_call()
|
|
|
|
|
|
def test_missing_sender_pubkey_raises(monkeypatch):
|
|
_wire(monkeypatch, machine=_machine(), super_config=_super())
|
|
with pytest.raises(ValueError, match="sender_pubkey"):
|
|
_call(sender=None)
|