package lightning_test import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "github.com/drjones/quantum-arcade/pkg/lightning" ) // These exercise the Alby Hub client against a mock of its REST API. // // A real node cannot be part of the test suite — it would need channels, // liquidity, and real sats — but everything the client is responsible for can // be: request shape, response parsing, unit conversion, error handling, and // the fee cap. Those are where a client loses money, not the routing. // albyMock stands in for Alby Hub. Handlers can be overridden per test. type albyMock struct { *httptest.Server lastPath string lastBody map[string]any invoiceResp string paymentResp string balanceResp string status int } func newAlbyMock(t *testing.T) *albyMock { t.Helper() m := &albyMock{ invoiceResp: `{"paymentHash":"abc123","invoice":"lnbc500n1...", "amount":500,"state":"unpaid", "expiresAt":"2030-01-01T00:00:00Z"}`, paymentResp: `{"paymentHash":"pay123","preimage":"deadbeef", "amountSat":500,"feesPaidSat":2,"state":"settled"}`, balanceResp: `{"lightning":{"totalSpendableSat":15000}}`, status: 200, } m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { m.lastPath = r.URL.Path if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&m.lastBody) } if auth := r.Header.Get("Authorization"); !strings.HasPrefix(auth, "Bearer ") { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"missing token"}`)) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(m.status) switch { case strings.Contains(r.URL.Path, "/invoices"): _, _ = w.Write([]byte(m.invoiceResp)) case strings.Contains(r.URL.Path, "/payments"): _, _ = w.Write([]byte(m.paymentResp)) case strings.Contains(r.URL.Path, "/balances"): _, _ = w.Write([]byte(m.balanceResp)) default: w.WriteHeader(http.StatusNotFound) } })) t.Cleanup(m.Close) return m } func TestAlbySendsBearerToken(t *testing.T) { m := newAlbyMock(t) node := lightning.NewAlbyNode(m.URL, "test-token") if _, err := node.Balance(context.Background()); err != nil { t.Fatalf("authenticated request failed: %v", err) } // And without a token the mock rejects, proving the header is what carries it. bare := lightning.NewAlbyNode(m.URL, "") if _, err := bare.Balance(context.Background()); err == nil { t.Fatal("a request with no token succeeded") } } // Alby speaks satoshis; the ledger speaks millisatoshis. A conversion error // here is a factor-of-1000 money bug. func TestAlbyConvertsSatsToMillisats(t *testing.T) { m := newAlbyMock(t) node := lightning.NewAlbyNode(m.URL, "tok") bal, err := node.Balance(context.Background()) if err != nil { t.Fatal(err) } if bal != 15_000_000 { t.Fatalf("balance = %d msat, want 15000000 (15000 sats)", bal) } inv, err := node.CreateInvoice(context.Background(), 500_000, "test") if err != nil { t.Fatal(err) } if inv.AmountMsat != 500_000 { t.Fatalf("invoice amount = %d msat, want 500000", inv.AmountMsat) } // The request must have asked for sats, not millisats. if got := m.lastBody["amount"]; got != float64(500) { t.Fatalf("asked Alby for amount %v, want 500 sats", got) } } // The fee cap must actually be enforced. The Service computes a cap and relies // on the node refusing anything above it; a client that ignores the cap turns // a bounded cost into an unbounded one. func TestAlbyRefusesPaymentAboveTheFeeCap(t *testing.T) { m := newAlbyMock(t) // Alby reports a 50-sat fee on this route. m.paymentResp = `{"paymentHash":"pay1","preimage":"ab","amountSat":1000, "feesPaidSat":50,"state":"settled"}` node := lightning.NewAlbyNode(m.URL, "tok") // Cap of 10 sats. 50 > 10, so this must fail. _, err := node.PayInvoice(context.Background(), "lnbc...", 10_000) if err == nil { t.Fatal("a payment costing 50 sats was accepted under a 10 sat cap") } if !errors.Is(err, lightning.ErrPaymentFailed) { t.Fatalf("got %v, want ErrPaymentFailed", err) } } func TestAlbyAcceptsPaymentWithinTheFeeCap(t *testing.T) { m := newAlbyMock(t) m.paymentResp = `{"paymentHash":"pay1","preimage":"ab","amountSat":1000, "feesPaidSat":2,"state":"settled"}` node := lightning.NewAlbyNode(m.URL, "tok") p, err := node.PayInvoice(context.Background(), "lnbc...", 10_000) if err != nil { t.Fatalf("a payment within the cap was refused: %v", err) } if p.FeeMsat != 2_000 { t.Fatalf("fee = %d msat, want 2000", p.FeeMsat) } } // A payment that has not settled must not be reported as success. Treating // "pending" as paid would credit a withdrawal that may still fail. func TestAlbyUnsettledPaymentIsAnError(t *testing.T) { m := newAlbyMock(t) m.paymentResp = `{"paymentHash":"p","preimage":"","amountSat":1000, "feesPaidSat":1,"state":"pending"}` node := lightning.NewAlbyNode(m.URL, "tok") if _, err := node.PayInvoice(context.Background(), "lnbc...", 100_000); err == nil { t.Fatal("a pending payment was reported as settled") } } func TestAlbyInvoiceSettlementState(t *testing.T) { m := newAlbyMock(t) node := lightning.NewAlbyNode(m.URL, "tok") m.invoiceResp = `{"paymentHash":"h","invoice":"lnbc","amount":300, "state":"unpaid","expiresAt":"2030-01-01T00:00:00Z"}` settled, amt, err := node.LookupInvoice(context.Background(), "h") if err != nil { t.Fatal(err) } if settled { t.Fatal("an unpaid invoice reported as settled") } if amt != 300_000 { t.Fatalf("amount = %d msat, want 300000", amt) } m.invoiceResp = `{"paymentHash":"h","invoice":"lnbc","amount":300, "state":"settled","expiresAt":"2030-01-01T00:00:00Z"}` settled, _, err = node.LookupInvoice(context.Background(), "h") if err != nil { t.Fatal(err) } if !settled { t.Fatal("a settled invoice reported as unpaid") } } // An error from the node must surface, not be swallowed into a zero value that // downstream code reads as "no balance" or "not settled". func TestAlbyErrorsSurface(t *testing.T) { m := newAlbyMock(t) m.status = 500 m.balanceResp = `{"error":"node offline"}` node := lightning.NewAlbyNode(m.URL, "tok") if _, err := node.Balance(context.Background()); err == nil { t.Fatal("a 500 from the node was not reported as an error") } } func TestAlbyMalformedResponseIsAnError(t *testing.T) { m := newAlbyMock(t) m.balanceResp = `not json at all` node := lightning.NewAlbyNode(m.URL, "tok") if _, err := node.Balance(context.Background()); err == nil { t.Fatal("a malformed response was accepted") } } // A withdrawal amount that does not divide into whole satoshis must not // silently short the player. Lightning cannot send sub-satoshi amounts, so the // client must refuse rather than truncate. func TestAlbyRefusesSubSatoshiPrecisionLoss(t *testing.T) { m := newAlbyMock(t) node := lightning.NewAlbyNode(m.URL, "tok") // 1500 msat is 1.5 sats. Truncating sends 1 sat while the ledger debited // 1500 msat, quietly costing the player 500 msat. _, err := node.CreateInvoice(context.Background(), 1_500, "dust") if err == nil { t.Fatal("an amount with sub-satoshi precision was accepted; " + "it would be truncated and the difference lost") } } // The client must respect a cancelled context rather than hanging. func TestAlbyHonoursContextCancellation(t *testing.T) { m := newAlbyMock(t) node := lightning.NewAlbyNode(m.URL, "tok") ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := node.Balance(ctx); err == nil { t.Fatal("a cancelled context did not stop the request") } } // The client must satisfy the Node interface the Service depends on. func TestAlbyImplementsNode(t *testing.T) { var _ lightning.Node = (*lightning.AlbyNode)(nil) }