From 96c6813ebc51c0bf9862f5959b9c851778d4a3c7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jos=C3=A9=20A=2EP?=
<53834183+Jossec101@users.noreply.github.com>
Date: Thu, 15 Jan 2026 17:27:14 +0100
Subject: [PATCH] [GEN-1859] Add audit log cleanup job with configurable
retention policy
- Add SwapOutInitiated, SwapOutCompleted, WalletSweep, and NodeWalletAssigned audit action types
- Create AuditLogCleanupJob with 180-day default retention period
- Add configurable constants for retention days and cleanup schedule
- Implement automatic daily cleanup at 3:00 AM via cron expression
- Support environment variable configuration for AUDIT_LOG_RETENTION_DAYS and AUDIT_LOG_CLEANUP_CRON
stack-info: PR: https://github.com/Elenpay/NodeGuard/pull/467, branch: Jossec101/stack/11
---
src/Data/Models/AuditLog.cs | 6 +
src/Helpers/Constants.cs | 18 ++
src/Jobs/AuditLogCleanupJob.cs | 62 ++++
src/Jobs/AutoLiquidityManagementJob.cs | 24 +-
src/Jobs/MonitorSwapsJob.cs | 31 +-
src/Jobs/SweepNodeWalletsJob.cs | 38 ++-
src/Program.cs | 17 +
.../Jobs/AuditLogCleanupJobTests.cs | 306 ++++++++++++++++++
.../Jobs/AutoLiquidityManagementJobTests.cs | 5 +-
9 files changed, 503 insertions(+), 4 deletions(-)
create mode 100644 src/Jobs/AuditLogCleanupJob.cs
create mode 100644 test/NodeGuard.Tests/Jobs/AuditLogCleanupJobTests.cs
diff --git a/src/Data/Models/AuditLog.cs b/src/Data/Models/AuditLog.cs
index 8e85acb2..87981046 100644
--- a/src/Data/Models/AuditLog.cs
+++ b/src/Data/Models/AuditLog.cs
@@ -140,6 +140,12 @@ public enum AuditActionType
// Swap Operations
SwapOut,
SwapIn,
+ SwapOutInitiated,
+ SwapOutCompleted,
+
+ // Wallet Sweep Operations
+ WalletSweep,
+ NodeWalletAssigned,
// Node Operations
AddNode,
diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs
index ac1a9962..1af5c803 100644
--- a/src/Helpers/Constants.cs
+++ b/src/Helpers/Constants.cs
@@ -75,6 +75,18 @@ public class Constants
///
public static readonly int AUTO_LIQUIDITY_MANAGEMENT_INTERVAL_MINUTES = 10;
+ ///
+ /// The number of days to retain audit log entries before automatic cleanup.
+ /// Default is 180 days. Can be configured via AUDIT_LOG_RETENTION_DAYS environment variable.
+ ///
+ public static readonly int AUDIT_LOG_RETENTION_DAYS = 180;
+
+ ///
+ /// Cron expression for the audit log cleanup job. Default runs daily at 3:00 AM.
+ /// Can be configured via AUDIT_LOG_CLEANUP_CRON environment variable.
+ ///
+ public static readonly string AUDIT_LOG_CLEANUP_CRON = "0 0 3 * * ?";
+
// Observability
public static readonly string? OTEL_EXPORTER_ENDPOINT;
@@ -244,6 +256,12 @@ static Constants()
var autoLiquidityManagementIntervalMinutes = Environment.GetEnvironmentVariable("AUTO_LIQUIDITY_MANAGEMENT_INTERVAL_MINUTES");
if (autoLiquidityManagementIntervalMinutes != null) AUTO_LIQUIDITY_MANAGEMENT_INTERVAL_MINUTES = int.Parse(autoLiquidityManagementIntervalMinutes);
+ // Audit Log
+ var auditLogRetentionDays = Environment.GetEnvironmentVariable("AUDIT_LOG_RETENTION_DAYS");
+ if (auditLogRetentionDays != null) AUDIT_LOG_RETENTION_DAYS = int.Parse(auditLogRetentionDays);
+
+ AUDIT_LOG_CLEANUP_CRON = Environment.GetEnvironmentVariable("AUDIT_LOG_CLEANUP_CRON") ?? AUDIT_LOG_CLEANUP_CRON;
+
// Observability
//We need to expand the env-var with %ENV_VAR% for K8S
var otelCollectorEndpointToBeExpanded = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT");
diff --git a/src/Jobs/AuditLogCleanupJob.cs b/src/Jobs/AuditLogCleanupJob.cs
new file mode 100644
index 00000000..bf3efc6c
--- /dev/null
+++ b/src/Jobs/AuditLogCleanupJob.cs
@@ -0,0 +1,62 @@
+/*
+ * 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 NodeGuard.Data.Repositories.Interfaces;
+using Quartz;
+
+namespace NodeGuard.Jobs;
+
+///
+/// Job that cleans up old audit log entries based on the configured retention policy
+///
+[DisallowConcurrentExecution]
+public class AuditLogCleanupJob : IJob
+{
+ private readonly IAuditLogRepository _auditLogRepository;
+ private readonly ILogger _logger;
+
+ public AuditLogCleanupJob(IAuditLogRepository auditLogRepository, ILogger logger)
+ {
+ _auditLogRepository = auditLogRepository;
+ _logger = logger;
+ }
+
+ public async Task Execute(IJobExecutionContext context)
+ {
+ _logger.LogInformation("Starting audit log cleanup job");
+
+ try
+ {
+ var retentionDays = Constants.AUDIT_LOG_RETENTION_DAYS;
+ var cutoffDate = DateTimeOffset.UtcNow.AddDays(-retentionDays);
+
+ _logger.LogInformation("Deleting audit logs older than {CutoffDate} (retention: {RetentionDays} days)",
+ cutoffDate, retentionDays);
+
+ var deletedCount = await _auditLogRepository.DeleteOlderThanAsync(cutoffDate);
+
+ _logger.LogInformation("Audit log cleanup completed. Deleted {DeletedCount} entries", deletedCount);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error during audit log cleanup");
+ throw;
+ }
+ }
+}
diff --git a/src/Jobs/AutoLiquidityManagementJob.cs b/src/Jobs/AutoLiquidityManagementJob.cs
index 0420f895..f6862684 100644
--- a/src/Jobs/AutoLiquidityManagementJob.cs
+++ b/src/Jobs/AutoLiquidityManagementJob.cs
@@ -53,6 +53,7 @@ public class AutoLiquidityManagementJob : IJob
private readonly ILightningService _lightningService;
private readonly IWalletRepository _walletRepository;
private readonly INBXplorerService _nbXplorerService;
+ private readonly IAuditService _auditService;
public AutoLiquidityManagementJob(
ILogger logger,
@@ -62,7 +63,8 @@ public AutoLiquidityManagementJob(
IFortySwapService fortySwapService,
ILightningService lightningService,
IWalletRepository walletRepository,
- INBXplorerService nbXplorerService)
+ INBXplorerService nbXplorerService,
+ IAuditService auditService)
{
_logger = logger;
_nodeRepository = nodeRepository;
@@ -72,6 +74,7 @@ public AutoLiquidityManagementJob(
_lightningService = lightningService;
_walletRepository = walletRepository;
_nbXplorerService = nbXplorerService;
+ _auditService = auditService;
}
public async Task Execute(IJobExecutionContext context)
@@ -290,6 +293,25 @@ public async Task ManageNodeLiquidity(Node node, Canc
_logger.LogInformation("Successfully initiated swap out {SwapId} for node {NodeName}",
swapOut.ProviderId, node.Name);
+
+ // Audit successful swap out initiation
+ await _auditService.LogSystemAsync(
+ AuditActionType.SwapOutInitiated,
+ AuditEventType.Success,
+ AuditObjectType.SwapOut,
+ swapOut.ProviderId,
+ new
+ {
+ NodeId = node.Id,
+ NodeName = node.Name,
+ Provider = selectedProvider.ToString(),
+ AmountSats = swapAmount,
+ DestinationAddress = destinationAddress,
+ DestinationWalletId = node.FundsDestinationWalletId,
+ ProviderId = swapResponse.Id,
+ IsAutomatic = true
+ });
+
return ManageNodeLiquidityResult.Success;
}
catch (Exception ex)
diff --git a/src/Jobs/MonitorSwapsJob.cs b/src/Jobs/MonitorSwapsJob.cs
index a055902b..21ff1dca 100644
--- a/src/Jobs/MonitorSwapsJob.cs
+++ b/src/Jobs/MonitorSwapsJob.cs
@@ -13,14 +13,16 @@ public class MonitorSwapsJob : IJob
private readonly INodeRepository _nodeRepository;
private readonly ISwapOutRepository _swapOutRepository;
private readonly ISwapsService _swapsService;
+ private readonly IAuditService _auditService;
- public MonitorSwapsJob(ILogger logger, ISchedulerFactory schedulerFactory, INodeRepository nodeRepository, ISwapOutRepository swapOutRepository, ISwapsService swapsService)
+ public MonitorSwapsJob(ILogger logger, ISchedulerFactory schedulerFactory, INodeRepository nodeRepository, ISwapOutRepository swapOutRepository, ISwapsService swapsService, IAuditService auditService)
{
_logger = logger;
_schedulerFactory = schedulerFactory;
_nodeRepository = nodeRepository;
_swapOutRepository = swapOutRepository;
_swapsService = swapsService;
+ _auditService = auditService;
}
private void CleanUp(SwapOut swap, string errorMessage)
@@ -67,6 +69,8 @@ public async Task Execute(IJobExecutionContext context)
if (response.Status != swap.Status)
{
+ var oldStatus = swap.Status;
+
if (response.Status == SwapOutStatus.Failed)
{
_logger.LogWarning("Swap {SwapId} status changed from {OldStatus} to {NewStatus}. Error: {ErrorMessage}",
@@ -89,6 +93,31 @@ public async Task Execute(IJobExecutionContext context)
_logger.LogError("Error updating swap {SwapId}: {Error}", swap.Id, error);
continue;
}
+
+ // Audit swap completion (only for successful completions)
+ if (response.Status == SwapOutStatus.Completed)
+ {
+ await _auditService.LogSystemAsync(
+ AuditActionType.SwapOutCompleted,
+ AuditEventType.Success,
+ AuditObjectType.SwapOut,
+ swap.ProviderId,
+ new
+ {
+ SwapId = swap.Id,
+ NodeId = swap.NodeId,
+ Provider = swap.Provider.ToString(),
+ ProviderId = swap.ProviderId,
+ AmountSats = swap.SatsAmount,
+ TotalFeeSats = swap.TotalFees.Satoshi,
+ ServiceFeeSats = swap.ServiceFeeSats,
+ LightningFeeSats = swap.LightningFeeSats,
+ OnChainFeeSats = swap.OnChainFeeSats,
+ IsManual = swap.IsManual,
+ OldStatus = oldStatus.ToString(),
+ NewStatus = response.Status.ToString()
+ });
+ }
}
}
}
diff --git a/src/Jobs/SweepNodeWalletsJob.cs b/src/Jobs/SweepNodeWalletsJob.cs
index 52040b97..0fc6b3a9 100644
--- a/src/Jobs/SweepNodeWalletsJob.cs
+++ b/src/Jobs/SweepNodeWalletsJob.cs
@@ -37,12 +37,14 @@ public class SweepNodeWalletsJob : IJob
private readonly INodeRepository _nodeRepository;
private readonly IWalletRepository _walletRepository;
private readonly INBXplorerService _nbXplorerService;
+ private readonly IAuditService _auditService;
public SweepNodeWalletsJob(ILogger logger,
INodeRepository nodeRepository,
IWalletRepository walletRepository,
INBXplorerService nbXplorerService,
- ILightningClientService lightningClientService)
+ ILightningClientService lightningClientService,
+ IAuditService auditService)
{
_logger = logger;
@@ -50,6 +52,7 @@ public SweepNodeWalletsJob(ILogger logger,
_walletRepository = walletRepository;
_nbXplorerService = nbXplorerService;
_lightningClientService = lightningClientService;
+ _auditService = auditService;
}
public async Task Execute(IJobExecutionContext context)
@@ -119,6 +122,23 @@ async Task SweepFunds(Node node, Wallet wallet, Lightning.LightningClient lightn
sendManyResponse.Txid,
returningAddress.Address);
+ // Audit successful wallet sweep
+ await _auditService.LogSystemAsync(
+ AuditActionType.WalletSweep,
+ AuditEventType.Success,
+ AuditObjectType.Wallet,
+ wallet.Id.ToString(),
+ new
+ {
+ NodeId = node.Id,
+ NodeName = node.Name,
+ WalletId = wallet.Id,
+ WalletName = wallet.Name,
+ AmountSats = sweepedFundsAmount,
+ TxId = sendManyResponse.Txid,
+ ReturnAddress = returningAddress.Address.ToString()
+ });
+
//TODO We need to store the txid somewhere to monitor it..
}
else
@@ -193,6 +213,22 @@ async Task SweepFunds(Node node, Wallet wallet, Lightning.LightningClient lightn
"Error while adding returning node wallet with id: {WalletId} to node: {NodeName}",
wallet.Id, node.Name);
}
+ else
+ {
+ // Audit successful wallet assignment
+ await _auditService.LogSystemAsync(
+ AuditActionType.NodeWalletAssigned,
+ AuditEventType.Success,
+ AuditObjectType.Node,
+ node.Id.ToString(),
+ new
+ {
+ NodeId = node.Id,
+ NodeName = node.Name,
+ WalletId = wallet.Id,
+ WalletName = wallet.Name
+ });
+ }
}
else
{
diff --git a/src/Program.cs b/src/Program.cs
index 612229d4..039b4c94 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -332,6 +332,23 @@ public static async Task Main(string[] args)
}
});
});
+
+ // Audit Log Cleanup Job
+ q.AddJob(opts =>
+ {
+ opts.DisallowConcurrentExecution();
+ opts.WithIdentity(nameof(AuditLogCleanupJob));
+ });
+
+ q.AddTrigger(opts =>
+ {
+ opts.ForJob(nameof(AuditLogCleanupJob))
+ .WithIdentity($"{nameof(AuditLogCleanupJob)}Trigger")
+ .StartNow().WithSimpleSchedule(scheduleBuilder =>
+ {
+ scheduleBuilder.WithIntervalInHours(24).RepeatForever();
+ });
+ });
});
// ASP.NET Core hosting
diff --git a/test/NodeGuard.Tests/Jobs/AuditLogCleanupJobTests.cs b/test/NodeGuard.Tests/Jobs/AuditLogCleanupJobTests.cs
new file mode 100644
index 00000000..d5e649fd
--- /dev/null
+++ b/test/NodeGuard.Tests/Jobs/AuditLogCleanupJobTests.cs
@@ -0,0 +1,306 @@
+/*
+ * 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.Repositories.Interfaces;
+using Quartz;
+
+namespace NodeGuard.Jobs;
+
+public class AuditLogCleanupJobTests
+{
+ private Mock _auditLogRepositoryMock;
+ private Mock> _loggerMock;
+ private AuditLogCleanupJob _auditLogCleanupJob;
+ private Mock _jobExecutionContextMock;
+
+ public AuditLogCleanupJobTests()
+ {
+ _auditLogRepositoryMock = new Mock();
+ _loggerMock = new Mock>();
+ _auditLogCleanupJob = new AuditLogCleanupJob(
+ _auditLogRepositoryMock.Object,
+ _loggerMock.Object
+ );
+ _jobExecutionContextMock = new Mock();
+ }
+
+ [Fact]
+ public async Task Execute_SuccessfulCleanup_DeletesRecordsAndLogsSuccess()
+ {
+ // Arrange
+ var deletedCount = 150;
+ _auditLogRepositoryMock
+ .Setup(x => x.DeleteOlderThanAsync(It.IsAny()))
+ .ReturnsAsync(deletedCount);
+
+ // Act
+ await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object);
+
+ // Assert
+ _auditLogRepositoryMock.Verify(
+ x => x.DeleteOlderThanAsync(It.IsAny()),
+ Times.Once);
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString().Contains("Starting audit log cleanup job")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString().Contains($"Deleted {deletedCount} entries")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Execute_NoRecordsToDelete_CompletesSuccessfully()
+ {
+ // Arrange
+ _auditLogRepositoryMock
+ .Setup(x => x.DeleteOlderThanAsync(It.IsAny()))
+ .ReturnsAsync(0);
+
+ // Act
+ await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object);
+
+ // Assert
+ _auditLogRepositoryMock.Verify(
+ x => x.DeleteOlderThanAsync(It.IsAny()),
+ Times.Once);
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString().Contains("Deleted 0 entries")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Execute_CalculatesCutoffDateCorrectly()
+ {
+ // Arrange
+ var beforeExecution = DateTimeOffset.UtcNow.AddDays(-Constants.AUDIT_LOG_RETENTION_DAYS);
+ DateTimeOffset capturedCutoffDate = DateTimeOffset.MinValue;
+
+ _auditLogRepositoryMock
+ .Setup(x => x.DeleteOlderThanAsync(It.IsAny()))
+ .Callback(cutoff => capturedCutoffDate = cutoff)
+ .ReturnsAsync(10);
+
+ // Act
+ await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object);
+ var afterExecution = DateTimeOffset.UtcNow.AddDays(-Constants.AUDIT_LOG_RETENTION_DAYS);
+
+ // Assert
+ capturedCutoffDate.Should().BeOnOrAfter(beforeExecution);
+ capturedCutoffDate.Should().BeOnOrBefore(afterExecution);
+ capturedCutoffDate.Offset.Should().Be(TimeSpan.Zero, "cutoff date should use UTC");
+ }
+
+ [Fact]
+ public async Task Execute_UsesConfiguredRetentionDays()
+ {
+ // Arrange
+ var expectedRetentionDays = Constants.AUDIT_LOG_RETENTION_DAYS;
+ DateTimeOffset capturedCutoffDate = DateTimeOffset.MinValue;
+
+ _auditLogRepositoryMock
+ .Setup(x => x.DeleteOlderThanAsync(It.IsAny()))
+ .Callback(cutoff => capturedCutoffDate = cutoff)
+ .ReturnsAsync(5);
+
+ // Act
+ await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object);
+
+ // Assert
+ var approximateExpectedCutoff = DateTimeOffset.UtcNow.AddDays(-expectedRetentionDays);
+ var difference = Math.Abs((capturedCutoffDate - approximateExpectedCutoff).TotalSeconds);
+ difference.Should().BeLessThan(2, "cutoff date should be calculated using the retention days constant");
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString().Contains($"retention: {expectedRetentionDays} days")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Execute_RepositoryThrowsException_LogsErrorAndRethrows()
+ {
+ // Arrange
+ var expectedException = new InvalidOperationException("Database connection failed");
+ _auditLogRepositoryMock
+ .Setup(x => x.DeleteOlderThanAsync(It.IsAny()))
+ .ThrowsAsync(expectedException);
+
+ // Act
+ var act = async () => await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("Database connection failed");
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString().Contains("Error during audit log cleanup")),
+ expectedException,
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Execute_LogsStartMessage()
+ {
+ // Arrange
+ _auditLogRepositoryMock
+ .Setup(x => x.DeleteOlderThanAsync(It.IsAny()))
+ .ReturnsAsync(0);
+
+ // Act
+ await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object);
+
+ // Assert
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString().Contains("Starting audit log cleanup job")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Execute_LogsCutoffDateAndRetentionDays()
+ {
+ // Arrange
+ _auditLogRepositoryMock
+ .Setup(x => x.DeleteOlderThanAsync(It.IsAny()))
+ .ReturnsAsync(25);
+
+ // Act
+ await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object);
+
+ // Assert
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) =>
+ v.ToString().Contains("Deleting audit logs older than") &&
+ v.ToString().Contains($"retention: {Constants.AUDIT_LOG_RETENTION_DAYS} days")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Execute_PassesCutoffDateToRepository()
+ {
+ // Arrange
+ DateTimeOffset capturedCutoffDate = DateTimeOffset.MinValue;
+ _auditLogRepositoryMock
+ .Setup(x => x.DeleteOlderThanAsync(It.IsAny()))
+ .Callback(cutoff => capturedCutoffDate = cutoff)
+ .ReturnsAsync(7);
+
+ // Act
+ await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object);
+
+ // 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");
+ }
+
+ [Fact]
+ 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)
+ .ThrowsAsync(exception2);
+
+ // Act & Assert - First execution
+ var act1 = async () => await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object);
+ await act1.Should().ThrowAsync();
+
+ // Act & Assert - Second execution
+ var act2 = async () => await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object);
+ await act2.Should().ThrowAsync();
+
+ // Assert both errors were logged
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString().Contains("Error during audit log cleanup")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Exactly(2));
+ }
+
+ [Fact]
+ public async Task Execute_LargeDeleteCount_LogsCorrectly()
+ {
+ // Arrange
+ var largeDeleteCount = 1_000_000;
+ _auditLogRepositoryMock
+ .Setup(x => x.DeleteOlderThanAsync(It.IsAny()))
+ .ReturnsAsync(largeDeleteCount);
+
+ // Act
+ await _auditLogCleanupJob.Execute(_jobExecutionContextMock.Object);
+
+ // Assert
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString().Contains($"Deleted {largeDeleteCount} entries")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+}
diff --git a/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs b/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs
index 42b29bcc..87b3fce0 100644
--- a/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs
+++ b/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs
@@ -41,6 +41,7 @@ public class AutoLiquidityManagementJobTests
private Mock _lightningServiceMock;
private Mock _walletRepositoryMock;
private Mock _nbXplorerServiceMock;
+ private Mock _auditServiceMock;
private AutoLiquidityManagementJob _autoLiquidityManagementJob;
public AutoLiquidityManagementJobTests()
@@ -53,6 +54,7 @@ public AutoLiquidityManagementJobTests()
_lightningServiceMock = new Mock();
_walletRepositoryMock = new Mock();
_nbXplorerServiceMock = new Mock();
+ _auditServiceMock = new Mock();
_autoLiquidityManagementJob = new AutoLiquidityManagementJob(
_loggerMock.Object,
@@ -62,7 +64,8 @@ public AutoLiquidityManagementJobTests()
_fortySwapServiceMock.Object,
_lightningServiceMock.Object,
_walletRepositoryMock.Object,
- _nbXplorerServiceMock.Object
+ _nbXplorerServiceMock.Object,
+ _auditServiceMock.Object
);
}