From 30fad08ae793c5d06628f091e891fde4b2dca09a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Thu, 7 May 2026 14:28:51 +0200 Subject: [PATCH] Add unit tests for RebalanceService and LightningService with various scenarios stack-info: PR: https://github.com/Elenpay/NodeGuard/pull/510, branch: Jossec101/stack/16 --- .../Jobs/AuditLogCleanupJobTests.cs | 6 +- .../Jobs/RetryRebalanceJobTests.cs | 78 +++ .../Rpc/NodeGuardServiceTests.cs | 12 +- .../LightningServiceRebalanceTests.cs | 132 +++++ .../Services/RebalanceServiceTests.cs | 475 ++++++++++++++++++ 5 files changed, 694 insertions(+), 9 deletions(-) create mode 100644 test/NodeGuard.Tests/Jobs/RetryRebalanceJobTests.cs create mode 100644 test/NodeGuard.Tests/Services/LightningServiceRebalanceTests.cs create mode 100644 test/NodeGuard.Tests/Services/RebalanceServiceTests.cs diff --git a/test/NodeGuard.Tests/Jobs/AuditLogCleanupJobTests.cs b/test/NodeGuard.Tests/Jobs/AuditLogCleanupJobTests.cs index d5e649fd..55eefb45 100644 --- a/test/NodeGuard.Tests/Jobs/AuditLogCleanupJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/AuditLogCleanupJobTests.cs @@ -220,7 +220,7 @@ public async Task Execute_LogsCutoffDateAndRetentionDays() x => x.Log( LogLevel.Information, It.IsAny(), - It.Is((v, t) => + It.Is((v, t) => v.ToString().Contains("Deleting audit logs older than") && v.ToString().Contains($"retention: {Constants.AUDIT_LOG_RETENTION_DAYS} days")), It.IsAny(), @@ -244,7 +244,7 @@ public async Task Execute_PassesCutoffDateToRepository() // Assert capturedCutoffDate.Should().NotBe(DateTimeOffset.MinValue, "repository should be called with a valid cutoff date"); capturedCutoffDate.Should().BeBefore(DateTimeOffset.UtcNow, "cutoff date should be in the past"); - + var expectedDaysAgo = DateTimeOffset.UtcNow.AddDays(-Constants.AUDIT_LOG_RETENTION_DAYS); var difference = Math.Abs((capturedCutoffDate - expectedDaysAgo).TotalMinutes); difference.Should().BeLessThan(1, "cutoff date should match the expected retention calculation"); @@ -256,7 +256,7 @@ public async Task Execute_MultipleExceptions_AllLogged() // Arrange var exception1 = new InvalidOperationException("First error"); var exception2 = new Exception("Second error"); - + _auditLogRepositoryMock .SetupSequence(x => x.DeleteOlderThanAsync(It.IsAny())) .ThrowsAsync(exception1) diff --git a/test/NodeGuard.Tests/Jobs/RetryRebalanceJobTests.cs b/test/NodeGuard.Tests/Jobs/RetryRebalanceJobTests.cs new file mode 100644 index 00000000..314f9955 --- /dev/null +++ b/test/NodeGuard.Tests/Jobs/RetryRebalanceJobTests.cs @@ -0,0 +1,78 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NodeGuard.Data.Models; +using NodeGuard.Services; +using Quartz; + +namespace NodeGuard.Jobs; + +public class RetryRebalanceJobTests +{ + private readonly Mock> _logger = new(); + private readonly Mock _rebalanceService = new(); + + private RebalanceJob CreateJob() => new(_logger.Object, _rebalanceService.Object); + + private static IJobExecutionContext BuildContext(int rebalanceId, CancellationToken token = default) + { + var ctxMock = new Mock(); + var data = new JobDataMap(); + data.Put("rebalanceId", rebalanceId); + ctxMock.Setup(c => c.MergedJobDataMap).Returns(data); + ctxMock.Setup(c => c.CancellationToken).Returns(token); + return ctxMock.Object; + } + + [Fact] + public async Task Execute_DelegatesToServiceWithRebalanceIdFromJobData() + { + _rebalanceService.Setup(s => s.ExecuteAsync(123, It.IsAny())) + .ReturnsAsync(new Rebalance { Id = 123, Status = RebalanceStatus.Succeeded }); + + await CreateJob().Execute(BuildContext(123)); + + _rebalanceService.Verify(s => s.ExecuteAsync(123, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Execute_PropagatesCancellationToken() + { + using var cts = new CancellationTokenSource(); + _rebalanceService.Setup(s => s.ExecuteAsync(7, cts.Token)) + .ReturnsAsync(new Rebalance { Id = 7 }); + + await CreateJob().Execute(BuildContext(7, cts.Token)); + + _rebalanceService.Verify(s => s.ExecuteAsync(7, cts.Token), Times.Once); + } + + [Fact] + public async Task Execute_ServiceThrows_RethrowsAsJobExecutionException() + { + _rebalanceService.Setup(s => s.ExecuteAsync(99, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + + var act = () => CreateJob().Execute(BuildContext(99)); + + await act.Should().ThrowAsync(); + } +} diff --git a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs index d972dad9..e9607e79 100644 --- a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs +++ b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs @@ -910,12 +910,12 @@ private static WalletRepository CreateWalletRepository(IDbContextFactory().Object); } - private static Mock CreateHtlcMonitoringSchedulerMock() - { - var mock = new Mock(); - mock.Setup(x => x.EnsureNodeWorkerScheduled(It.IsAny())).ReturnsAsync(true); - return mock; - } + private static Mock CreateHtlcMonitoringSchedulerMock() + { + var mock = new Mock(); + mock.Setup(x => x.EnsureNodeWorkerScheduled(It.IsAny())).ReturnsAsync(true); + return mock; + } private static ISchedulerFactory GetSchedulerFactoryMock() { diff --git a/test/NodeGuard.Tests/Services/LightningServiceRebalanceTests.cs b/test/NodeGuard.Tests/Services/LightningServiceRebalanceTests.cs new file mode 100644 index 00000000..b4d4a791 --- /dev/null +++ b/test/NodeGuard.Tests/Services/LightningServiceRebalanceTests.cs @@ -0,0 +1,132 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using FluentAssertions; +using Lnrpc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NodeGuard.Data; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; + +namespace NodeGuard.Services; + +/// +/// Tests for the rebalance-specific methods added to LightningService: +/// ProbeRouteAsync (BoS-style binary-search probing) and +/// GetLocalOutboundFeeRatePpmAsync (correct policy-side selection on a ChannelEdge). +/// +public class LightningServiceRebalanceTests +{ + private readonly Mock> _logger = new(); + private readonly Mock _lightningClient = new(); + private readonly Mock _lightningRouter = new(); + private readonly Mock> _dbContextFactory = new(); + + private LightningService CreateService() => new( + _logger.Object, + new Mock().Object, + new Mock().Object, + _dbContextFactory.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _lightningClient.Object, + _lightningRouter.Object); + + private static Node CreateNode(string pubkey = "030000000000000000000000000000000000000000000000000000000000000001") + => new() { Id = 1, PubKey = pubkey, Endpoint = "localhost:10009", ChannelAdminMacaroon = "mac" }; + + private static Lnrpc.Route MakeRoute(long amtMsat = 100_000_000) + { + var route = new Lnrpc.Route { TotalAmtMsat = amtMsat, TotalFeesMsat = 0 }; + route.Hops.Add(new Hop { ChanId = 1, AmtToForwardMsat = amtMsat }); + return route; + } + + [Fact] + public async Task ProbeRouteAsync_QueryRoutesAlwaysEmpty_ReturnsNoRoute() + { + _lightningClient + .Setup(x => x.QueryRoutes(It.IsAny(), It.IsAny(), null)) + .ReturnsAsync(new QueryRoutesResponse()); + + var service = CreateService(); + var result = await service.ProbeRouteAsync(CreateNode(), 100_000, 1_000_000, + null, null, Constants.REBALANCE_PROBE_BACKOFF_RATIO, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task ProbeRouteAsync_LastHopIncorrectPaymentDetails_ReturnsSuccessAtFullAmount() + { + var route = MakeRoute(); + var routesResponse = new QueryRoutesResponse(); + routesResponse.Routes.Add(route); + _lightningClient + .Setup(x => x.QueryRoutes(It.IsAny(), It.IsAny(), null)) + .ReturnsAsync(routesResponse); + + // The probe-success signal: random hash failed at the last hop, but routing worked. + _lightningRouter + .Setup(x => x.SendToRouteV2Async(It.IsAny(), It.IsAny(), + It.IsAny(), null)) + .ReturnsAsync(new HTLCAttempt + { + Failure = new Failure { Code = Failure.Types.FailureCode.IncorrectOrUnknownPaymentDetails }, + }); + + var service = CreateService(); + var result = await service.ProbeRouteAsync(CreateNode(), 100_000, 1_000_000, + null, null, Constants.REBALANCE_PROBE_BACKOFF_RATIO, CancellationToken.None); + + var success = result.Should().BeOfType().Which; + success.AmountSats.Should().Be(100_000); + success.Route.Should().BeSameAs(route); + } + + [Fact] + public async Task ProbeRouteAsync_AllRoutesFailMidPath_HalvesUntilBelowMinThenReturnsNoRoute() + { + var routesResponse = new QueryRoutesResponse(); + routesResponse.Routes.Add(MakeRoute()); + _lightningClient + .Setup(x => x.QueryRoutes(It.IsAny(), It.IsAny(), null)) + .ReturnsAsync(routesResponse); + + // Mid-route failure: not the IncorrectOrUnknownPaymentDetails signal. + _lightningRouter + .Setup(x => x.SendToRouteV2Async(It.IsAny(), It.IsAny(), + It.IsAny(), null)) + .ReturnsAsync(new HTLCAttempt + { + Failure = new Failure { Code = Failure.Types.FailureCode.TemporaryChannelFailure }, + }); + + var service = CreateService(); + var result = await service.ProbeRouteAsync(CreateNode(), 100_000, 1_000_000, + null, null, Constants.REBALANCE_PROBE_BACKOFF_RATIO, CancellationToken.None); + + result.Should().BeOfType(); + } + +} diff --git a/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs b/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs new file mode 100644 index 00000000..7dac1f10 --- /dev/null +++ b/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs @@ -0,0 +1,475 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using FluentAssertions; +using Lnrpc; +using Microsoft.Extensions.Logging; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; +using NodeGuard.Helpers; +using NodeGuard.Jobs; +using Quartz; +using Channel = NodeGuard.Data.Models.Channel; + +namespace NodeGuard.Services; + +public class RebalanceServiceTests +{ + private readonly Mock> _logger = new(); + private readonly Mock _nodeRepo = new(); + private readonly Mock _channelRepo = new(); + private readonly Mock _rebalanceRepo = new(); + private readonly Mock _lightning = new(); + private readonly Mock _audit = new(); + private readonly Mock _schedulerFactory = new(); + private readonly Mock _scheduler = new(); + + public RebalanceServiceTests() + { + _schedulerFactory.Setup(x => x.GetScheduler(It.IsAny())) + .ReturnsAsync(_scheduler.Object); + } + + private RebalanceService CreateService() => new( + _logger.Object, + _nodeRepo.Object, + _channelRepo.Object, + _rebalanceRepo.Object, + _lightning.Object, + _audit.Object, + _schedulerFactory.Object); + + private static Node CreateNode(int id = 1, string pubkey = "030000000000000000000000000000000000000000000000000000000000000001") + => new() + { + Id = id, + Name = $"node-{id}", + PubKey = pubkey, + Endpoint = "localhost:10009", + ChannelAdminMacaroon = "mac", + }; + + /// + /// Wires up the repository mocks so that AddAsync stamps an Id and GetById returns the + /// same instance — mimicking what EF would do without an actual database. + /// + private Rebalance? StubRepoForCapture(int newId = 42) + { + Rebalance? captured = null; + _rebalanceRepo.Setup(r => r.AddAsync(It.IsAny())) + .Callback(r => { r.Id = newId; captured = r; }) + .ReturnsAsync((true, (string?)null)); + _rebalanceRepo.Setup(r => r.GetById(newId)) + .ReturnsAsync(() => captured); + _rebalanceRepo.Setup(r => r.Update(It.IsAny())) + .Returns((true, (string?)null)); + return captured; + } + + [Fact] + public async Task RebalanceAsync_AmountZero_ThrowsWithTargetEqualsCurrentMessage() + { + // Amount=0 is what the modal computes when target inbound % equals (or is below) current inbound %. + // The service must reject this with a clear, user-facing message. + var service = CreateService(); + var request = new RebalanceRequest(NodeId: 1, null, null, AmountSats: 0, MaxFeePct: null); + + await FluentActions.Awaiting(() => service.RebalanceAsync(request)) + .Should().ThrowAsync() + .WithMessage("*already at or above the requested inbound ratio*"); + } + + [Fact] + public async Task RebalanceAsync_ProbeBackoffRatioOutOfRange_Throws() + { + // Ratio must be in (0, 1) exclusive. 1.0 never shrinks; 0 zeroes the next try. + var service = CreateService(); + var request = new RebalanceRequest(NodeId: 1, null, null, + AmountSats: 1000, MaxFeePct: null, ProbeBackoffRatio: 1.0); + + await FluentActions.Awaiting(() => service.RebalanceAsync(request)) + .Should().ThrowAsync() + .WithMessage("*Probe backoff ratio must be in the open interval (0, 1)*"); + } + + [Fact] + public async Task RebalanceAsync_MaxAttemptsZeroOrNegative_Throws() + { + var service = CreateService(); + var request = new RebalanceRequest(NodeId: 1, null, null, + AmountSats: 1000, MaxFeePct: null, MaxAttempts: 0); + + await FluentActions.Awaiting(() => service.RebalanceAsync(request)) + .Should().ThrowAsync() + .WithMessage("*Max attempts must be at least 1*"); + } + + [Fact] + public async Task RebalanceAsync_RetryMaxFeePctOutOfRange_Throws() + { + var service = CreateService(); + var request = new RebalanceRequest(NodeId: 1, null, null, + AmountSats: 1000, MaxFeePct: null, RetryMaxFeePct: -0.1); + + await FluentActions.Awaiting(() => service.RebalanceAsync(request)) + .Should().ThrowAsync() + .WithMessage("*Retry max fee % must be greater than 0*"); + } + + [Fact] + public async Task RebalanceAsync_NodeNotFound_Throws() + { + _nodeRepo.Setup(x => x.GetById(99, It.IsAny())).ReturnsAsync((Node?)null); + var service = CreateService(); + var request = new RebalanceRequest(NodeId: 99, null, null, AmountSats: 1000, MaxFeePct: null); + + await FluentActions.Awaiting(() => service.RebalanceAsync(request)) + .Should().ThrowAsync(); + } + + [Fact] + public async Task RebalanceAsync_UserSuppliedFeePct_PersistedAsIs() + { + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + StubRepoForCapture(); + + // Probe returns NoRoute so we short-circuit before payment. + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProbeResult.NoRoute("test")); + + var service = CreateService(); + var request = new RebalanceRequest(NodeId: node.Id, null, null, + AmountSats: 100_000, MaxFeePct: 0.1234); + + var result = await service.RebalanceAsync(request); + + result.MaxFeePct.Should().Be(0.1234); + } + + + [Fact] + public async Task RebalanceAsync_UserSuppliedProbeBackoffRatio_PersistedAndPassedToProbe() + { + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + StubRepoForCapture(); + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + + double capturedRatio = 0; + _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, _, _, _, ratio, _) => capturedRatio = ratio) + .ReturnsAsync(new ProbeResult.NoRoute("test")); + + var service = CreateService(); + var request = new RebalanceRequest(NodeId: node.Id, null, null, + AmountSats: 100_000, MaxFeePct: 0.025, ProbeBackoffRatio: 0.8); + + var result = await service.RebalanceAsync(request); + + result.ProbeBackoffRatio.Should().Be(0.8); + capturedRatio.Should().Be(0.8); + } + + [Fact] + public async Task RebalanceAsync_NullProbeBackoffRatio_FallsBackToConstant() + { + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + StubRepoForCapture(); + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + + double capturedRatio = 0; + _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, _, _, _, ratio, _) => capturedRatio = ratio) + .ReturnsAsync(new ProbeResult.NoRoute("test")); + + var service = CreateService(); + var request = new RebalanceRequest(NodeId: node.Id, null, null, + AmountSats: 100_000, MaxFeePct: 0.025, ProbeBackoffRatio: null); + + var result = await service.RebalanceAsync(request); + + result.ProbeBackoffRatio.Should().BeNull(); + capturedRatio.Should().Be(Constants.REBALANCE_PROBE_BACKOFF_RATIO); + } + + [Fact] + public async Task RebalanceAsync_NoUserFeePct_FallsBackToDefaultFeePct() + { + // When no MaxFeePct is supplied the service must derive it from + // Constants.REBALANCE_DEFAULT_MAX_FEE_PCT (0.05). No LND outbound-rate call should be made. + var node = CreateNode(); + var peerPubkey = "030000000000000000000000000000000000000000000000000000000000000002"; + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + StubRepoForCapture(); + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + + // Make the rebalance succeed so retry-escalation doesn't bump ppm before assertion. + _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProbeResult.Success(100_000, new Lnrpc.Route())); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Succeeded, FeeMsat = 1000 }); + + var service = CreateService(); + var request = new RebalanceRequest(NodeId: node.Id, null, TargetPubkey: peerPubkey, + AmountSats: 100_000, MaxFeePct: null); + + var result = await service.RebalanceAsync(request); + + result.MaxFeePct.Should().Be((double)Constants.REBALANCE_DEFAULT_MAX_FEE_PCT); + result.TargetPubkey.Should().Be(peerPubkey); + result.AttemptNumber.Should().Be(1); + // GetLocalOutboundFeeRatePpmByPeerAsync must NOT be called — fee is % of amount only. + _lightning.Verify(x => x.GetLocalOutboundFeeRatePpmByPeerAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ExecuteAsync_ProbeSucceeds_PaymentSucceeds_StatusSucceeded() + { + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + StubRepoForCapture(); + + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + + var route = new Lnrpc.Route(); + route.Hops.Add(new Hop { ChanId = 1, AmtToForwardMsat = 100_000_000 }); + _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProbeResult.Success(100_000, route)); + + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment + { + Status = Payment.Types.PaymentStatus.Succeeded, + FeeMsat = 12_345, + PaymentPreimage = "deadbeef", + }); + + var service = CreateService(); + var request = new RebalanceRequest(node.Id, null, null, 100_000, MaxFeePct: 0.05); + var result = await service.RebalanceAsync(request); + + result.Status.Should().Be(RebalanceStatus.Succeeded); + result.FeePaidMsat.Should().Be(12_345); + result.FeePaidSats.Should().Be(12); + result.PreimageHex.Should().Be("deadbeef"); + } + + [Fact] + public async Task ExecuteAsync_ProbeNoRoute_StatusNoRoute_RetryScheduled() + { + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + StubRepoForCapture(); + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProbeResult.NoRoute("exhausted")); + + var service = CreateService(); + var request = new RebalanceRequest(node.Id, null, null, 100_000, MaxFeePct: 0.05); + var result = await service.RebalanceAsync(request); + + // After scheduling a retry, AttemptNumber is bumped to 2 and Status is reset to Pending. + result.Status.Should().Be(RebalanceStatus.Pending); + result.AttemptNumber.Should().Be(2); + _scheduler.Verify(s => s.ScheduleJob(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ExecuteAsync_PaymentInsufficientBalance_NoRetryScheduled() + { + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + StubRepoForCapture(); + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProbeResult.Success(100_000, new Lnrpc.Route())); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment + { + Status = Payment.Types.PaymentStatus.Failed, + FailureReason = PaymentFailureReason.FailureReasonInsufficientBalance, + }); + + var service = CreateService(); + var request = new RebalanceRequest(node.Id, null, null, 100_000, MaxFeePct: 0.05); + var result = await service.RebalanceAsync(request); + + result.Status.Should().Be(RebalanceStatus.InsufficientBalance); + result.AttemptNumber.Should().Be(1); + _scheduler.Verify(s => s.ScheduleJob(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ExecuteAsync_ScheduleRetry_EscalatesFeePctFromInitialToRetry() + { + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + + StubRepoForCapture(); + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProbeResult.NoRoute()); + + var service = CreateService(); + var request = new RebalanceRequest(node.Id, null, TargetPubkey: null, + AmountSats: 100_000, MaxFeePct: null); + var result = await service.RebalanceAsync(request); + + var initialPct = (double)Constants.REBALANCE_DEFAULT_MAX_FEE_PCT; + var retryPct = (double)Constants.REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT; + // Retry keeps the higher of initial and retry caps. + result.MaxFeePct.Should().Be(Math.Max(initialPct, retryPct)); + result.AttemptNumber.Should().Be(2); + } + + [Fact] + public async Task ExecuteAsync_PerRowRetryMaxFeePct_OverridesConstantOnEscalation() + { + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + StubRepoForCapture(); + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProbeResult.NoRoute()); + + var service = CreateService(); + // Per-row retry pct = 0.09%, overriding the constant default + // (REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT = 0.05%). After one NoRoute the + // escalated cap should be max(initial=0.05%, retry=0.09%) = 0.09%. + var request = new RebalanceRequest(node.Id, null, TargetPubkey: null, + AmountSats: 100_000, MaxFeePct: null, RetryMaxFeePct: 0.09); + var result = await service.RebalanceAsync(request); + + result.MaxFeePct.Should().Be(0.09); + result.AttemptNumber.Should().Be(2); + result.RetryMaxFeePct.Should().Be(0.09); + } + + [Fact] + public async Task ExecuteAsync_PerRowMaxAttempts1_NoRetryScheduled() + { + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + StubRepoForCapture(); + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProbeResult.NoRoute()); + + var service = CreateService(); + // MaxAttempts=1 → first attempt is also the last; no Quartz retry should be scheduled. + var request = new RebalanceRequest(node.Id, null, null, + AmountSats: 100_000, MaxFeePct: 0.025, MaxAttempts: 1); + var result = await service.RebalanceAsync(request); + + result.Status.Should().Be(RebalanceStatus.NoRoute); + result.AttemptNumber.Should().Be(1); + _scheduler.Verify(s => s.ScheduleJob(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ExecuteAsync_AtMaxAttempts_NoFurtherRetryScheduled() + { + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + + // Pre-built rebalance row at the max attempt. ExecuteAsync should not schedule another retry. + var existing = new Rebalance + { + Id = 100, + NodeId = node.Id, + Node = node, + Status = RebalanceStatus.Pending, + AttemptNumber = Constants.REBALANCE_MAX_ATTEMPTS, + RequestedAmountSats = 100_000, + SatsAmount = 100_000, + MaxFeePct = 0.05, + TimeoutSeconds = 60, + }; + _rebalanceRepo.Setup(r => r.GetById(existing.Id)).ReturnsAsync(existing); + _rebalanceRepo.Setup(r => r.Update(It.IsAny())).Returns((true, (string?)null)); + + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProbeResult.NoRoute()); + + var service = CreateService(); + var result = await service.ExecuteAsync(existing.Id); + + result.Status.Should().Be(RebalanceStatus.NoRoute); // terminal, no escalation + result.AttemptNumber.Should().Be(Constants.REBALANCE_MAX_ATTEMPTS); + _scheduler.Verify(s => s.ScheduleJob(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RebalanceAsync_AuditsInitiation() + { + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + StubRepoForCapture(); + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProbeResult.NoRoute()); + + var service = CreateService(); + await service.RebalanceAsync(new RebalanceRequest(node.Id, null, null, AmountSats: 100_000, MaxFeePct: 0.1, TimeoutSeconds: 500)); + + _audit.Verify(a => a.LogAsync( + AuditActionType.RebalanceInitiated, + AuditEventType.Attempt, + AuditObjectType.Rebalance, + It.IsAny(), + It.IsAny()), + Times.Once); + } +}