diff --git a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Founder/FounderAppServiceTests.cs b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Founder/FounderAppServiceTests.cs new file mode 100644 index 000000000..588ad37e5 --- /dev/null +++ b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Founder/FounderAppServiceTests.cs @@ -0,0 +1,245 @@ +using Angor.Data.Documents.Interfaces; +using Angor.Sdk.Common; +using Angor.Sdk.Funding.Founder.Operations; +using Angor.Sdk.Funding.Projects.Domain; +using Angor.Sdk.Funding.Services; +using Angor.Sdk.Funding.Shared; +using Angor.Sdk.Tests.Shared; +using Angor.Shared.Models; +using Angor.Shared.Services; +using CSharpFunctionalExtensions; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Angor.Sdk.Tests.Funding.Founder; + +/// +/// Unit tests for Founder App Service handlers. +/// Tests the GetProjectInvestments, CreateProjectKeys, and GetReleasableTransactions handlers. +/// +public class FounderAppServiceTests : IClassFixture +{ + private readonly TestNetworkFixture _fixture; + private readonly Mock _mockProjectService; + private readonly Mock _mockAngorIndexerService; + private readonly Mock _mockInvestmentHandshakeService; + private readonly Mock> _mockDerivedProjectKeysCollection; + + public FounderAppServiceTests(TestNetworkFixture fixture) + { + _fixture = fixture; + _mockProjectService = new Mock(); + _mockAngorIndexerService = new Mock(); + _mockInvestmentHandshakeService = new Mock(); + _mockDerivedProjectKeysCollection = new Mock>(); + } + + #region GetProjectInvestmentsHandler Tests + + [Fact] + public async Task GetProjectInvestmentsHandler_WhenProjectNotFound_ReturnsFailure() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var projectId = new ProjectId("test-project"); + + _mockProjectService + .Setup(x => x.GetAsync(projectId)) + .ReturnsAsync(Result.Failure("Project not found")); + + var handler = new GetProjectInvestments.GetProjectInvestmentsHandler( + _mockAngorIndexerService.Object, + _mockProjectService.Object, + _fixture.NetworkConfiguration, + _mockInvestmentHandshakeService.Object); + + var request = new GetProjectInvestments.GetProjectInvestmentsRequest(walletId, projectId); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Project not found"); + } + + [Fact] + public async Task GetProjectInvestmentsHandler_WhenHandshakeSyncFails_ReturnsFailure() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var project = TestDataBuilder.CreateProject().Build(); + + _mockProjectService + .Setup(x => x.GetAsync(project.Id)) + .ReturnsAsync(Result.Success(project)); + + _mockInvestmentHandshakeService + .Setup(x => x.SyncHandshakesFromNostrAsync(walletId, project.Id, project.NostrPubKey)) + .ReturnsAsync(Result.Failure>("Nostr sync failed")); + + var handler = new GetProjectInvestments.GetProjectInvestmentsHandler( + _mockAngorIndexerService.Object, + _mockProjectService.Object, + _fixture.NetworkConfiguration, + _mockInvestmentHandshakeService.Object); + + var request = new GetProjectInvestments.GetProjectInvestmentsRequest(walletId, project.Id); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Nostr sync failed"); + } + + [Fact] + public async Task GetProjectInvestmentsHandler_WhenNoHandshakes_ReturnsEmptyList() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var project = TestDataBuilder.CreateProject().Build(); + + _mockProjectService + .Setup(x => x.GetAsync(project.Id)) + .ReturnsAsync(Result.Success(project)); + + _mockInvestmentHandshakeService + .Setup(x => x.SyncHandshakesFromNostrAsync(walletId, project.Id, project.NostrPubKey)) + .ReturnsAsync(Result.Success>(Enumerable.Empty())); + + _mockInvestmentHandshakeService + .Setup(x => x.GetHandshakesAsync(walletId, project.Id)) + .ReturnsAsync(Result.Success>(Enumerable.Empty())); + + _mockAngorIndexerService + .Setup(x => x.GetInvestmentsAsync(project.Id.Value)) + .ReturnsAsync(new List()); + + var handler = new GetProjectInvestments.GetProjectInvestmentsHandler( + _mockAngorIndexerService.Object, + _mockProjectService.Object, + _fixture.NetworkConfiguration, + _mockInvestmentHandshakeService.Object); + + var request = new GetProjectInvestments.GetProjectInvestmentsRequest(walletId, project.Id); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Investments.Should().BeEmpty(); + } + + [Fact] + public async Task GetProjectInvestmentsHandler_CallsProjectServiceWithCorrectId() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var projectId = new ProjectId("specific-project-id"); + + _mockProjectService + .Setup(x => x.GetAsync(projectId)) + .ReturnsAsync(Result.Failure("Not found")); + + var handler = new GetProjectInvestments.GetProjectInvestmentsHandler( + _mockAngorIndexerService.Object, + _mockProjectService.Object, + _fixture.NetworkConfiguration, + _mockInvestmentHandshakeService.Object); + + var request = new GetProjectInvestments.GetProjectInvestmentsRequest(walletId, projectId); + + // Act + await handler.Handle(request, CancellationToken.None); + + // Assert + _mockProjectService.Verify(x => x.GetAsync(projectId), Times.Once); + } + + [Fact] + public async Task GetProjectInvestmentsHandler_SyncsHandshakesFromNostr() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var project = TestDataBuilder.CreateProject().Build(); + + _mockProjectService + .Setup(x => x.GetAsync(project.Id)) + .ReturnsAsync(Result.Success(project)); + + _mockInvestmentHandshakeService + .Setup(x => x.SyncHandshakesFromNostrAsync(walletId, project.Id, project.NostrPubKey)) + .ReturnsAsync(Result.Success>(Enumerable.Empty())); + + _mockInvestmentHandshakeService + .Setup(x => x.GetHandshakesAsync(walletId, project.Id)) + .ReturnsAsync(Result.Success>(Enumerable.Empty())); + + _mockAngorIndexerService + .Setup(x => x.GetInvestmentsAsync(project.Id.Value)) + .ReturnsAsync(new List()); + + var handler = new GetProjectInvestments.GetProjectInvestmentsHandler( + _mockAngorIndexerService.Object, + _mockProjectService.Object, + _fixture.NetworkConfiguration, + _mockInvestmentHandshakeService.Object); + + var request = new GetProjectInvestments.GetProjectInvestmentsRequest(walletId, project.Id); + + // Act + await handler.Handle(request, CancellationToken.None); + + // Assert + _mockInvestmentHandshakeService.Verify( + x => x.SyncHandshakesFromNostrAsync(walletId, project.Id, project.NostrPubKey), + Times.Once); + } + + [Fact] + public async Task GetProjectInvestmentsHandler_LooksUpCurrentInvestmentsFromIndexer() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var project = TestDataBuilder.CreateProject().Build(); + + _mockProjectService + .Setup(x => x.GetAsync(project.Id)) + .ReturnsAsync(Result.Success(project)); + + _mockInvestmentHandshakeService + .Setup(x => x.SyncHandshakesFromNostrAsync(walletId, project.Id, project.NostrPubKey)) + .ReturnsAsync(Result.Success>(Enumerable.Empty())); + + _mockInvestmentHandshakeService + .Setup(x => x.GetHandshakesAsync(walletId, project.Id)) + .ReturnsAsync(Result.Success>(Enumerable.Empty())); + + _mockAngorIndexerService + .Setup(x => x.GetInvestmentsAsync(project.Id.Value)) + .ReturnsAsync(new List()); + + var handler = new GetProjectInvestments.GetProjectInvestmentsHandler( + _mockAngorIndexerService.Object, + _mockProjectService.Object, + _fixture.NetworkConfiguration, + _mockInvestmentHandshakeService.Object); + + var request = new GetProjectInvestments.GetProjectInvestmentsRequest(walletId, project.Id); + + // Act + await handler.Handle(request, CancellationToken.None); + + // Assert + _mockAngorIndexerService.Verify( + x => x.GetInvestmentsAsync(project.Id.Value), + Times.Once); + } + + #endregion +} diff --git a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/InvestmentAppServiceTests.cs b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/InvestmentAppServiceTests.cs new file mode 100644 index 000000000..4535c6db4 --- /dev/null +++ b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Investor/Operations/InvestmentAppServiceTests.cs @@ -0,0 +1,527 @@ +using Angor.Sdk.Common; +using Angor.Sdk.Funding.Investor.Domain; +using Angor.Sdk.Funding.Investor.Operations; +using Angor.Sdk.Funding.Projects; +using Angor.Sdk.Funding.Projects.Domain; +using Angor.Sdk.Funding.Services; +using Angor.Sdk.Funding.Shared; +using Angor.Sdk.Tests.Shared; +using Angor.Shared; +using Angor.Shared.Models; +using Angor.Shared.Services; +using CSharpFunctionalExtensions; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Angor.Sdk.Tests.Funding.Investor.Operations; + +/// +/// Unit tests for Investment App Service handlers. +/// Tests the GetInvestments, GetRecoveryStatus, and GetPenalties handlers. +/// +public class InvestmentAppServiceTests : IClassFixture +{ + private readonly TestNetworkFixture _fixture; + private readonly Mock _mockProjectService; + private readonly Mock _mockPortfolioService; + private readonly Mock _mockAngorIndexerService; + private readonly Mock _mockInvestmentHandshakeService; + private readonly Mock _mockTransactionService; + private readonly Mock _mockProjectInvestmentsService; + + public InvestmentAppServiceTests(TestNetworkFixture fixture) + { + _fixture = fixture; + _mockProjectService = new Mock(); + _mockPortfolioService = new Mock(); + _mockAngorIndexerService = new Mock(); + _mockInvestmentHandshakeService = new Mock(); + _mockTransactionService = new Mock(); + _mockProjectInvestmentsService = new Mock(); + } + + #region GetInvestmentsHandler Tests + + [Fact] + public async Task GetInvestmentsHandler_WhenPortfolioServiceFails_ReturnsFailure() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + + _mockPortfolioService + .Setup(x => x.GetByWalletId(walletId.Value)) + .ReturnsAsync(Result.Failure("Failed to retrieve investments")); + + var handler = new GetInvestments.GetInvestmentsHandler( + _mockAngorIndexerService.Object, + _mockPortfolioService.Object, + _mockProjectService.Object, + _mockInvestmentHandshakeService.Object, + _fixture.NetworkConfiguration, + new NullLogger()); + + var request = new GetInvestments.GetInvestmentsRequest(walletId); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Failed to retrieve investment records"); + } + + [Fact] + public async Task GetInvestmentsHandler_WhenNoInvestments_ReturnsEmptyList() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var emptyRecords = new InvestmentRecords { ProjectIdentifiers = new List() }; + + _mockPortfolioService + .Setup(x => x.GetByWalletId(walletId.Value)) + .ReturnsAsync(Result.Success(emptyRecords)); + + var handler = new GetInvestments.GetInvestmentsHandler( + _mockAngorIndexerService.Object, + _mockPortfolioService.Object, + _mockProjectService.Object, + _mockInvestmentHandshakeService.Object, + _fixture.NetworkConfiguration, + new NullLogger()); + + var request = new GetInvestments.GetInvestmentsRequest(walletId); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Projects.Should().BeEmpty(); + } + + [Fact] + public async Task GetInvestmentsHandler_WhenProjectServiceFails_ReturnsFailure() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var projectId = "test-project"; + var records = new InvestmentRecords + { + ProjectIdentifiers = new List + { + new InvestmentRecord { ProjectIdentifier = projectId, InvestorPubKey = "investor-pub-key" } + } + }; + + _mockPortfolioService + .Setup(x => x.GetByWalletId(walletId.Value)) + .ReturnsAsync(Result.Success(records)); + + _mockProjectService + .Setup(x => x.GetAllAsync(It.IsAny())) + .ReturnsAsync(Result.Failure>("Failed to get projects")); + + var handler = new GetInvestments.GetInvestmentsHandler( + _mockAngorIndexerService.Object, + _mockPortfolioService.Object, + _mockProjectService.Object, + _mockInvestmentHandshakeService.Object, + _fixture.NetworkConfiguration, + new NullLogger()); + + var request = new GetInvestments.GetInvestmentsRequest(walletId); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Failed to retrieve projects"); + } + + [Fact] + public async Task GetInvestmentsHandler_CallsPortfolioServiceWithCorrectWalletId() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var emptyRecords = new InvestmentRecords { ProjectIdentifiers = new List() }; + + _mockPortfolioService + .Setup(x => x.GetByWalletId(walletId.Value)) + .ReturnsAsync(Result.Success(emptyRecords)); + + var handler = new GetInvestments.GetInvestmentsHandler( + _mockAngorIndexerService.Object, + _mockPortfolioService.Object, + _mockProjectService.Object, + _mockInvestmentHandshakeService.Object, + _fixture.NetworkConfiguration, + new NullLogger()); + + var request = new GetInvestments.GetInvestmentsRequest(walletId); + + // Act + await handler.Handle(request, CancellationToken.None); + + // Assert + _mockPortfolioService.Verify(x => x.GetByWalletId(walletId.Value), Times.Once); + } + + [Fact] + public async Task GetInvestmentsHandler_WhenInvestmentsExist_QueriesProjectsForAllIds() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var projectId1 = "project-1"; + var projectId2 = "project-2"; + + var records = new InvestmentRecords + { + ProjectIdentifiers = new List + { + new InvestmentRecord { ProjectIdentifier = projectId1, InvestorPubKey = "investor-1" }, + new InvestmentRecord { ProjectIdentifier = projectId2, InvestorPubKey = "investor-2" } + } + }; + + _mockPortfolioService + .Setup(x => x.GetByWalletId(walletId.Value)) + .ReturnsAsync(Result.Success(records)); + + _mockProjectService + .Setup(x => x.GetAllAsync(It.IsAny())) + .ReturnsAsync(Result.Failure>("Not found")); + + var handler = new GetInvestments.GetInvestmentsHandler( + _mockAngorIndexerService.Object, + _mockPortfolioService.Object, + _mockProjectService.Object, + _mockInvestmentHandshakeService.Object, + _fixture.NetworkConfiguration, + new NullLogger()); + + var request = new GetInvestments.GetInvestmentsRequest(walletId); + + // Act + await handler.Handle(request, CancellationToken.None); + + // Assert + _mockProjectService.Verify( + x => x.GetAllAsync(It.Is(ids => + ids.Length == 2 && + ids.Any(id => id.Value == projectId1) && + ids.Any(id => id.Value == projectId2))), + Times.Once); + } + + #endregion + + #region GetRecoveryStatusHandler Tests + + [Fact] + public async Task GetRecoveryStatusHandler_WhenProjectNotFound_ReturnsFailure() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var projectId = new ProjectId("non-existent-project"); + + _mockProjectService + .Setup(x => x.GetAsync(projectId)) + .ReturnsAsync(Result.Failure("Project not found")); + + var handler = new GetRecoveryStatus.GetRecoveryStatusHandler( + _mockProjectService.Object, + _mockPortfolioService.Object, + _mockAngorIndexerService.Object, + _fixture.NetworkConfiguration, + _fixture.InvestorTransactionActions, + _mockProjectInvestmentsService.Object, + _mockTransactionService.Object); + + var request = new GetRecoveryStatus.GetRecoveryStatusRequest(walletId, projectId); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Project not found"); + } + + [Fact] + public async Task GetRecoveryStatusHandler_WhenPortfolioFails_ReturnsFailure() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var project = TestDataBuilder.CreateProject().Build(); + + _mockProjectService + .Setup(x => x.GetAsync(project.Id)) + .ReturnsAsync(Result.Success(project)); + + _mockPortfolioService + .Setup(x => x.GetByWalletId(walletId.Value)) + .ReturnsAsync(Result.Failure("Portfolio service error")); + + var handler = new GetRecoveryStatus.GetRecoveryStatusHandler( + _mockProjectService.Object, + _mockPortfolioService.Object, + _mockAngorIndexerService.Object, + _fixture.NetworkConfiguration, + _fixture.InvestorTransactionActions, + _mockProjectInvestmentsService.Object, + _mockTransactionService.Object); + + var request = new GetRecoveryStatus.GetRecoveryStatusRequest(walletId, project.Id); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Portfolio service error"); + } + + [Fact] + public async Task GetRecoveryStatusHandler_WhenNoInvestmentsForWallet_ReturnsFailure() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var project = TestDataBuilder.CreateProject().Build(); + var emptyRecords = new InvestmentRecords { ProjectIdentifiers = new List() }; + + _mockProjectService + .Setup(x => x.GetAsync(project.Id)) + .ReturnsAsync(Result.Success(project)); + + _mockPortfolioService + .Setup(x => x.GetByWalletId(walletId.Value)) + .ReturnsAsync(Result.Success(emptyRecords)); + + var handler = new GetRecoveryStatus.GetRecoveryStatusHandler( + _mockProjectService.Object, + _mockPortfolioService.Object, + _mockAngorIndexerService.Object, + _fixture.NetworkConfiguration, + _fixture.InvestorTransactionActions, + _mockProjectInvestmentsService.Object, + _mockTransactionService.Object); + + var request = new GetRecoveryStatus.GetRecoveryStatusRequest(walletId, project.Id); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("No investments found"); + } + + [Fact] + public async Task GetRecoveryStatusHandler_WhenNoInvestmentForProject_ReturnsFailure() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var project = TestDataBuilder.CreateProject().Build(); + var records = new InvestmentRecords + { + ProjectIdentifiers = new List + { + new InvestmentRecord { ProjectIdentifier = "different-project", InvestorPubKey = "investor-pub-key" } + } + }; + + _mockProjectService + .Setup(x => x.GetAsync(project.Id)) + .ReturnsAsync(Result.Success(project)); + + _mockPortfolioService + .Setup(x => x.GetByWalletId(walletId.Value)) + .ReturnsAsync(Result.Success(records)); + + var handler = new GetRecoveryStatus.GetRecoveryStatusHandler( + _mockProjectService.Object, + _mockPortfolioService.Object, + _mockAngorIndexerService.Object, + _fixture.NetworkConfiguration, + _fixture.InvestorTransactionActions, + _mockProjectInvestmentsService.Object, + _mockTransactionService.Object); + + var request = new GetRecoveryStatus.GetRecoveryStatusRequest(walletId, project.Id); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("No investments found for this project"); + } + + [Fact] + public async Task GetRecoveryStatusHandler_CallsProjectServiceWithCorrectId() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var projectId = new ProjectId("specific-project"); + + _mockProjectService + .Setup(x => x.GetAsync(projectId)) + .ReturnsAsync(Result.Failure("Not found")); + + var handler = new GetRecoveryStatus.GetRecoveryStatusHandler( + _mockProjectService.Object, + _mockPortfolioService.Object, + _mockAngorIndexerService.Object, + _fixture.NetworkConfiguration, + _fixture.InvestorTransactionActions, + _mockProjectInvestmentsService.Object, + _mockTransactionService.Object); + + var request = new GetRecoveryStatus.GetRecoveryStatusRequest(walletId, projectId); + + // Act + await handler.Handle(request, CancellationToken.None); + + // Assert + _mockProjectService.Verify(x => x.GetAsync(projectId), Times.Once); + } + + #endregion + + #region GetPenaltiesHandler Tests + + [Fact] + public async Task GetPenaltiesHandler_WhenPortfolioServiceFails_ReturnsFailure() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + + _mockPortfolioService + .Setup(x => x.GetByWalletId(walletId.Value)) + .ReturnsAsync(Result.Failure("Portfolio lookup failed")); + + var handler = new GetPenalties.GetPenaltiesHandler( + _mockPortfolioService.Object, + _mockAngorIndexerService.Object, + _mockTransactionService.Object, + _mockProjectInvestmentsService.Object, + _mockProjectService.Object); + + var request = new GetPenalties.GetPenaltiesRequest(walletId); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Portfolio lookup failed"); + } + + [Fact] + public async Task GetPenaltiesHandler_WhenNoInvestments_ReturnsEmptyList() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var emptyRecords = new InvestmentRecords { ProjectIdentifiers = new List() }; + + _mockPortfolioService + .Setup(x => x.GetByWalletId(walletId.Value)) + .ReturnsAsync(Result.Success(emptyRecords)); + + var handler = new GetPenalties.GetPenaltiesHandler( + _mockPortfolioService.Object, + _mockAngorIndexerService.Object, + _mockTransactionService.Object, + _mockProjectInvestmentsService.Object, + _mockProjectService.Object); + + var request = new GetPenalties.GetPenaltiesRequest(walletId); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Penalties.Should().BeEmpty(); + } + + [Fact] + public async Task GetPenaltiesHandler_CallsPortfolioServiceWithCorrectWalletId() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var emptyRecords = new InvestmentRecords { ProjectIdentifiers = new List() }; + + _mockPortfolioService + .Setup(x => x.GetByWalletId(walletId.Value)) + .ReturnsAsync(Result.Success(emptyRecords)); + + var handler = new GetPenalties.GetPenaltiesHandler( + _mockPortfolioService.Object, + _mockAngorIndexerService.Object, + _mockTransactionService.Object, + _mockProjectInvestmentsService.Object, + _mockProjectService.Object); + + var request = new GetPenalties.GetPenaltiesRequest(walletId); + + // Act + await handler.Handle(request, CancellationToken.None); + + // Assert + _mockPortfolioService.Verify(x => x.GetByWalletId(walletId.Value), Times.Once); + } + + [Fact] + public async Task GetPenaltiesHandler_WhenInvestmentsExist_QueriesIndexerForEach() + { + // Arrange + var walletId = new WalletId(Guid.NewGuid().ToString()); + var projectId = "test-project"; + var investorPubKey = "investor-pub-key"; + + var records = new InvestmentRecords + { + ProjectIdentifiers = new List + { + new InvestmentRecord { ProjectIdentifier = projectId, InvestorPubKey = investorPubKey } + } + }; + + var projects = new List { TestDataBuilder.CreateProject().WithId(projectId).Build() }; + + _mockPortfolioService + .Setup(x => x.GetByWalletId(walletId.Value)) + .ReturnsAsync(Result.Success(records)); + + _mockAngorIndexerService + .Setup(x => x.GetInvestmentAsync(projectId, investorPubKey)) + .ReturnsAsync((ProjectInvestment?)null); + + _mockProjectService + .Setup(x => x.GetAllAsync(It.IsAny())) + .ReturnsAsync(Result.Success>(projects)); + + var handler = new GetPenalties.GetPenaltiesHandler( + _mockPortfolioService.Object, + _mockAngorIndexerService.Object, + _mockTransactionService.Object, + _mockProjectInvestmentsService.Object, + _mockProjectService.Object); + + var request = new GetPenalties.GetPenaltiesRequest(walletId); + + // Act + await handler.Handle(request, CancellationToken.None); + + // Assert + _mockAngorIndexerService.Verify( + x => x.GetInvestmentAsync(projectId, investorPubKey), + Times.Once); + } + + #endregion +} diff --git a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Projects/ProjectAppServiceTests.cs b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Projects/ProjectAppServiceTests.cs new file mode 100644 index 000000000..11e129138 --- /dev/null +++ b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Projects/ProjectAppServiceTests.cs @@ -0,0 +1,435 @@ +using Angor.Sdk.Funding.Projects; +using Angor.Sdk.Funding.Projects.Domain; +using Angor.Sdk.Funding.Projects.Operations; +using Angor.Sdk.Funding.Services; +using Angor.Sdk.Funding.Shared; +using Angor.Sdk.Tests.Shared; +using CSharpFunctionalExtensions; +using FluentAssertions; +using Moq; +using Xunit; + +namespace Angor.Sdk.Tests.Funding.Projects; + +/// +/// Unit tests for Project App Service handlers. +/// Tests the LatestProjects, TryGetProject, GetProject, and ProjectStatistics handlers. +/// +public class ProjectAppServiceTests : IClassFixture +{ + private readonly TestNetworkFixture _fixture; + private readonly Mock _mockProjectService; + private readonly Mock _mockProjectInvestmentsService; + + public ProjectAppServiceTests(TestNetworkFixture fixture) + { + _fixture = fixture; + _mockProjectService = new Mock(); + _mockProjectInvestmentsService = new Mock(); + } + + #region LatestProjectsHandler Tests + + [Fact] + public async Task LatestProjectsHandler_WhenProjectsExist_ReturnsProjects() + { + // Arrange + var projects = new List + { + TestDataBuilder.CreateProject().WithName("Project 1").Build(), + TestDataBuilder.CreateProject().WithName("Project 2").Build(), + TestDataBuilder.CreateProject().WithName("Project 3").Build() + }; + + _mockProjectService + .Setup(x => x.LatestFromNostrAsync()) + .ReturnsAsync(Result.Success>(projects)); + + var handler = new LatestProjects.LatestProjectsHandler(_mockProjectService.Object); + var request = new LatestProjects.LatestProjectsRequest(); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Projects.Should().HaveCount(3); + result.Value.Projects.Select(p => p.Name).Should().Contain("Project 1"); + result.Value.Projects.Select(p => p.Name).Should().Contain("Project 2"); + result.Value.Projects.Select(p => p.Name).Should().Contain("Project 3"); + } + + [Fact] + public async Task LatestProjectsHandler_WhenNoProjects_ReturnsEmptyList() + { + // Arrange + _mockProjectService + .Setup(x => x.LatestFromNostrAsync()) + .ReturnsAsync(Result.Success>(Enumerable.Empty())); + + var handler = new LatestProjects.LatestProjectsHandler(_mockProjectService.Object); + var request = new LatestProjects.LatestProjectsRequest(); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Projects.Should().BeEmpty(); + } + + [Fact] + public async Task LatestProjectsHandler_WhenServiceFails_ReturnsFailure() + { + // Arrange + _mockProjectService + .Setup(x => x.LatestFromNostrAsync()) + .ReturnsAsync(Result.Failure>("Failed to fetch projects from Nostr")); + + var handler = new LatestProjects.LatestProjectsHandler(_mockProjectService.Object); + var request = new LatestProjects.LatestProjectsRequest(); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Failed to fetch projects"); + } + + [Fact] + public async Task LatestProjectsHandler_CallsLatestFromNostrAsync() + { + // Arrange + _mockProjectService + .Setup(x => x.LatestFromNostrAsync()) + .ReturnsAsync(Result.Success>(Enumerable.Empty())); + + var handler = new LatestProjects.LatestProjectsHandler(_mockProjectService.Object); + var request = new LatestProjects.LatestProjectsRequest(); + + // Act + await handler.Handle(request, CancellationToken.None); + + // Assert + _mockProjectService.Verify(x => x.LatestFromNostrAsync(), Times.Once); + } + + #endregion + + #region GetProjectHandler Tests + + [Fact] + public async Task GetProjectHandler_WhenProjectExists_ReturnsProject() + { + // Arrange + var projectId = "test-project-id"; + var project = TestDataBuilder.CreateProject() + .WithId(projectId) + .WithName("Test Project") + .Build(); + + _mockProjectService + .Setup(x => x.GetAsync(It.Is(p => p.Value == projectId))) + .ReturnsAsync(Result.Success(project)); + + var handler = new GetProject.GetProjectHandler(_mockProjectService.Object); + var request = new GetProject.GetProjectRequest(new ProjectId(projectId)); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Project.Name.Should().Be("Test Project"); + } + + [Fact] + public async Task GetProjectHandler_WhenProjectNotFound_ReturnsFailure() + { + // Arrange + var projectId = "non-existent-project"; + + _mockProjectService + .Setup(x => x.GetAsync(It.Is(p => p.Value == projectId))) + .ReturnsAsync(Result.Failure("Project not found")); + + var handler = new GetProject.GetProjectHandler(_mockProjectService.Object); + var request = new GetProject.GetProjectRequest(new ProjectId(projectId)); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Project not found"); + } + + [Fact] + public async Task GetProjectHandler_CallsProjectServiceWithCorrectId() + { + // Arrange + var projectId = "specific-project-id"; + var project = TestDataBuilder.CreateProject().WithId(projectId).Build(); + + _mockProjectService + .Setup(x => x.GetAsync(It.IsAny())) + .ReturnsAsync(Result.Success(project)); + + var handler = new GetProject.GetProjectHandler(_mockProjectService.Object); + var request = new GetProject.GetProjectRequest(new ProjectId(projectId)); + + // Act + await handler.Handle(request, CancellationToken.None); + + // Assert + _mockProjectService.Verify( + x => x.GetAsync(It.Is(p => p.Value == projectId)), + Times.Once); + } + + #endregion + + #region TryGetProjectHandler Tests + + [Fact] + public async Task TryGetProjectHandler_WhenProjectExists_ReturnsMaybeWithProject() + { + // Arrange + var projectId = "existing-project"; + var project = TestDataBuilder.CreateProject() + .WithId(projectId) + .WithName("Existing Project") + .Build(); + + _mockProjectService + .Setup(x => x.TryGetAsync(It.Is(p => p.Value == projectId))) + .ReturnsAsync(Result.Success(Maybe.From(project))); + + var handler = new TryGetProject.TryGetProjectHandler(_mockProjectService.Object); + var request = new TryGetProject.TryGetProjectRequest(new ProjectId(projectId)); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Project.HasValue.Should().BeTrue(); + result.Value.Project.Value.Name.Should().Be("Existing Project"); + } + + [Fact] + public async Task TryGetProjectHandler_WhenProjectNotFound_ReturnsMaybeNone() + { + // Arrange + var projectId = "non-existent-project"; + + _mockProjectService + .Setup(x => x.TryGetAsync(It.Is(p => p.Value == projectId))) + .ReturnsAsync(Result.Success(Maybe.None)); + + var handler = new TryGetProject.TryGetProjectHandler(_mockProjectService.Object); + var request = new TryGetProject.TryGetProjectRequest(new ProjectId(projectId)); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Project.HasNoValue.Should().BeTrue(); + } + + [Fact] + public async Task TryGetProjectHandler_WhenServiceFails_ReturnsFailure() + { + // Arrange + var projectId = "test-project"; + + _mockProjectService + .Setup(x => x.TryGetAsync(It.IsAny())) + .ReturnsAsync(Result.Failure>("Database connection failed")); + + var handler = new TryGetProject.TryGetProjectHandler(_mockProjectService.Object); + var request = new TryGetProject.TryGetProjectRequest(new ProjectId(projectId)); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Database connection failed"); + } + + [Fact] + public async Task TryGetProjectHandler_CallsTryGetAsyncWithCorrectId() + { + // Arrange + var projectId = "project-to-try"; + + _mockProjectService + .Setup(x => x.TryGetAsync(It.IsAny())) + .ReturnsAsync(Result.Success(Maybe.None)); + + var handler = new TryGetProject.TryGetProjectHandler(_mockProjectService.Object); + var request = new TryGetProject.TryGetProjectRequest(new ProjectId(projectId)); + + // Act + await handler.Handle(request, CancellationToken.None); + + // Assert + _mockProjectService.Verify( + x => x.TryGetAsync(It.Is(p => p.Value == projectId)), + Times.Once); + } + + #endregion + + #region ProjectStatsHandler Tests + + [Fact] + public async Task ProjectStatsHandler_WhenNoInvestments_ReturnsZeroStats() + { + // Arrange + var projectId = "project-with-no-investments"; + var project = TestDataBuilder.CreateProject().WithId(projectId).Build(); + + _mockProjectInvestmentsService + .Setup(x => x.ScanFullInvestments(projectId)) + .ReturnsAsync(Result.Success>(Enumerable.Empty())); + + _mockProjectService + .Setup(x => x.GetAsync(It.Is(p => p.Value == projectId))) + .ReturnsAsync(Result.Success(project)); + + var handler = new ProjectStatistics.ProjectStatsHandler( + _mockProjectInvestmentsService.Object, + _mockProjectService.Object); + var request = new ProjectStatistics.ProjectStatsRequest(new ProjectId(projectId)); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.TotalStages.Should().Be(0); + result.Value.NextStage.Should().BeNull(); + } + + [Fact] + public async Task ProjectStatsHandler_WhenScanFails_ReturnsFailure() + { + // Arrange + var projectId = "failing-project"; + + _mockProjectInvestmentsService + .Setup(x => x.ScanFullInvestments(projectId)) + .ReturnsAsync(Result.Failure>("Failed to scan investments")); + + var handler = new ProjectStatistics.ProjectStatsHandler( + _mockProjectInvestmentsService.Object, + _mockProjectService.Object); + var request = new ProjectStatistics.ProjectStatsRequest(new ProjectId(projectId)); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Failed to scan investments"); + } + + [Fact] + public async Task ProjectStatsHandler_WhenStagesExist_ReturnsCorrectStageCount() + { + // Arrange + var projectId = "project-with-investments"; + var project = TestDataBuilder.CreateProject().WithId(projectId).WithStages(3).Build(); + + var stageData = new List + { + TestDataBuilder.CreateStageData().WithStageIndex(0).WithStageDate(DateTime.UtcNow.AddDays(-5)).Build(), + TestDataBuilder.CreateStageData().WithStageIndex(1).WithStageDate(DateTime.UtcNow.AddDays(5)).Build(), + TestDataBuilder.CreateStageData().WithStageIndex(2).WithStageDate(DateTime.UtcNow.AddDays(15)).Build() + }; + + _mockProjectInvestmentsService + .Setup(x => x.ScanFullInvestments(projectId)) + .ReturnsAsync(Result.Success>(stageData)); + + _mockProjectService + .Setup(x => x.GetAsync(It.Is(p => p.Value == projectId))) + .ReturnsAsync(Result.Success(project)); + + var handler = new ProjectStatistics.ProjectStatsHandler( + _mockProjectInvestmentsService.Object, + _mockProjectService.Object); + var request = new ProjectStatistics.ProjectStatsRequest(new ProjectId(projectId)); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.TotalStages.Should().Be(3); + } + + [Fact] + public async Task ProjectStatsHandler_CallsScanFullInvestments() + { + // Arrange + var projectId = "project-to-scan"; + var project = TestDataBuilder.CreateProject().WithId(projectId).Build(); + + _mockProjectInvestmentsService + .Setup(x => x.ScanFullInvestments(projectId)) + .ReturnsAsync(Result.Success>(Enumerable.Empty())); + + _mockProjectService + .Setup(x => x.GetAsync(It.IsAny())) + .ReturnsAsync(Result.Success(project)); + + var handler = new ProjectStatistics.ProjectStatsHandler( + _mockProjectInvestmentsService.Object, + _mockProjectService.Object); + var request = new ProjectStatistics.ProjectStatsRequest(new ProjectId(projectId)); + + // Act + await handler.Handle(request, CancellationToken.None); + + // Assert + _mockProjectInvestmentsService.Verify( + x => x.ScanFullInvestments(projectId), + Times.Once); + } + + [Fact] + public async Task ProjectStatsHandler_CallsProjectServiceForProjectInfo() + { + // Arrange + var projectId = "project-needing-info"; + var project = TestDataBuilder.CreateProject().WithId(projectId).Build(); + + _mockProjectInvestmentsService + .Setup(x => x.ScanFullInvestments(projectId)) + .ReturnsAsync(Result.Success>(Enumerable.Empty())); + + _mockProjectService + .Setup(x => x.GetAsync(It.Is(p => p.Value == projectId))) + .ReturnsAsync(Result.Success(project)); + + var handler = new ProjectStatistics.ProjectStatsHandler( + _mockProjectInvestmentsService.Object, + _mockProjectService.Object); + var request = new ProjectStatistics.ProjectStatsRequest(new ProjectId(projectId)); + + // Act + await handler.Handle(request, CancellationToken.None); + + // Assert + _mockProjectService.Verify( + x => x.GetAsync(It.Is(p => p.Value == projectId)), + Times.Once); + } + + #endregion +} diff --git a/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Projects/ProjectInvestmentsServiceTests.cs b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Projects/ProjectInvestmentsServiceTests.cs new file mode 100644 index 000000000..6e631775c --- /dev/null +++ b/src/Angor/Avalonia/Angor.Sdk.Tests/Funding/Projects/ProjectInvestmentsServiceTests.cs @@ -0,0 +1,403 @@ +using Angor.Sdk.Funding.Projects; +using Angor.Sdk.Funding.Projects.Domain; +using Angor.Sdk.Funding.Services; +using Angor.Sdk.Funding.Shared; +using Angor.Sdk.Tests.Shared; +using Angor.Shared; +using Angor.Shared.Models; +using Angor.Shared.Protocol; +using Angor.Shared.Services; +using Blockcore.Consensus.ScriptInfo; +using Blockcore.Consensus.TransactionInfo; +using Blockcore.NBitcoin; +using CSharpFunctionalExtensions; +using FluentAssertions; +using Moq; +using Xunit; + +namespace Angor.Sdk.Tests.Funding.Projects; + +/// +/// Unit tests for ProjectInvestmentsService. +/// Tests the scanning of investments and spending detection. +/// +public class ProjectInvestmentsServiceTests : IClassFixture +{ + private readonly TestNetworkFixture _fixture; + private readonly Mock _mockProjectService; + private readonly Mock _mockAngorIndexerService; + private readonly Mock _mockTransactionService; + private readonly ProjectInvestmentsService _sut; + + public ProjectInvestmentsServiceTests(TestNetworkFixture fixture) + { + _fixture = fixture; + _mockProjectService = new Mock(); + _mockAngorIndexerService = new Mock(); + _mockTransactionService = new Mock(); + + _sut = new ProjectInvestmentsService( + _mockProjectService.Object, + _fixture.NetworkConfiguration, + _mockAngorIndexerService.Object, + _fixture.InvestorTransactionActions, + _mockTransactionService.Object); + } + + #region ScanFullInvestments Tests + + [Fact] + public async Task ScanFullInvestments_WhenProjectNotFound_ReturnsFailure() + { + // Arrange + var projectId = "test-project-id"; + + _mockProjectService + .Setup(x => x.GetAsync(It.Is(p => p.Value == projectId))) + .ReturnsAsync(Result.Failure("Project not found")); + + // Act + var result = await _sut.ScanFullInvestments(projectId); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Failed to retrieve project data"); + } + + [Fact] + public async Task ScanFullInvestments_WhenNoInvestments_ReturnsEmptyList() + { + // Arrange + var project = TestDataBuilder.CreateProject().Build(); + + _mockProjectService + .Setup(x => x.GetAsync(It.IsAny())) + .ReturnsAsync(Result.Success(project)); + + _mockAngorIndexerService + .Setup(x => x.GetInvestmentsAsync(project.Id.Value)) + .ReturnsAsync(new List()); + + // Act + var result = await _sut.ScanFullInvestments(project.Id.Value); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeEmpty(); + } + + [Fact] + public async Task ScanFullInvestments_WhenIndexerThrows_ReturnsFailure() + { + // Arrange + var project = TestDataBuilder.CreateProject().Build(); + + _mockProjectService + .Setup(x => x.GetAsync(It.IsAny())) + .ReturnsAsync(Result.Success(project)); + + _mockAngorIndexerService + .Setup(x => x.GetInvestmentsAsync(project.Id.Value)) + .ThrowsAsync(new HttpRequestException("Network error")); + + // Act + var result = await _sut.ScanFullInvestments(project.Id.Value); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Network error"); + } + + [Fact] + public async Task ScanFullInvestments_CallsProjectServiceWithCorrectId() + { + // Arrange + var projectId = "specific-project-id"; + var project = TestDataBuilder.CreateProject().WithId(projectId).Build(); + + _mockProjectService + .Setup(x => x.GetAsync(It.Is(p => p.Value == projectId))) + .ReturnsAsync(Result.Success(project)); + + _mockAngorIndexerService + .Setup(x => x.GetInvestmentsAsync(projectId)) + .ReturnsAsync(new List()); + + // Act + await _sut.ScanFullInvestments(projectId); + + // Assert + _mockProjectService.Verify( + x => x.GetAsync(It.Is(p => p.Value == projectId)), + Times.Once); + } + + [Fact] + public async Task ScanFullInvestments_CallsIndexerServiceWithCorrectProjectId() + { + // Arrange + var projectId = "test-project-123"; + var project = TestDataBuilder.CreateProject().WithId(projectId).Build(); + + _mockProjectService + .Setup(x => x.GetAsync(It.IsAny())) + .ReturnsAsync(Result.Success(project)); + + _mockAngorIndexerService + .Setup(x => x.GetInvestmentsAsync(projectId)) + .ReturnsAsync(new List()); + + // Act + await _sut.ScanFullInvestments(projectId); + + // Assert + _mockAngorIndexerService.Verify( + x => x.GetInvestmentsAsync(projectId), + Times.Once); + } + + #endregion + + #region CheckSpentFund Tests + + [Fact] + public async Task CheckSpentFund_WhenOutputIsNull_ReturnsFailure() + { + // Arrange + var projectInfo = TestDataBuilder.CreateProjectInfo().Build(); + var transaction = _fixture.Network.CreateTransaction(); + + // Act + var result = await _sut.CheckSpentFund(null, transaction, projectInfo, 0); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Output not found"); + } + + [Fact] + public async Task CheckSpentFund_WhenOutputNotSpent_ReturnsUnspentItem() + { + // Arrange + var projectInfo = TestDataBuilder.CreateProjectInfo().Build(); + + // Create a valid investment transaction with taproot outputs + var transaction = CreateMockInvestmentTransaction(projectInfo, 3); + + var output = new QueryTransactionOutput + { + Index = 2, // First stage output (index 2 after anchor outputs) + Balance = 100000, + SpentInTransaction = null // Not spent + }; + + // Act + var result = await _sut.CheckSpentFund(output, transaction, projectInfo, 0); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.IsSpent.Should().BeFalse(); + result.Value.Trxid.Should().Be(transaction.GetHash().ToString()); + } + + [Fact(Skip = "Requires properly constructed taproot transactions with valid scripts. The InvestorTransactionActions.DiscoverUsedScript method needs real taproot scripts to function properly.")] + public async Task CheckSpentFund_WhenOutputSpent_ReturnsSpentItem() + { + // Arrange + var projectInfo = TestDataBuilder.CreateProjectInfo().Build(); + var transaction = CreateMockInvestmentTransaction(projectInfo, 3); + var spentInTxId = "spent-tx-id-123"; + + var output = new QueryTransactionOutput + { + Index = 2, + Balance = 100000, + SpentInTransaction = spentInTxId + }; + + // Mock the spent transaction info + var spentTxInfo = TestDataBuilder.CreateQueryTransaction() + .WithTransactionId(spentInTxId) + .AddInput(transaction.GetHash().ToString(), 2, "") + .Build(); + + _mockTransactionService + .Setup(x => x.GetTransactionInfoByIdAsync(spentInTxId)) + .ReturnsAsync(spentTxInfo); + + // Act + var result = await _sut.CheckSpentFund(output, transaction, projectInfo, 0); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.IsSpent.Should().BeTrue(); + } + + #endregion + + #region ScanInvestmentSpends Tests + + [Fact] + public async Task ScanInvestmentSpends_WhenTransactionNotFound_ReturnsFailure() + { + // Arrange + var projectInfo = TestDataBuilder.CreateProjectInfo().Build(); + var transactionId = "non-existent-tx"; + + _mockTransactionService + .Setup(x => x.GetTransactionInfoByIdAsync(transactionId)) + .ReturnsAsync((QueryTransaction?)null); + + // Act + var result = await _sut.ScanInvestmentSpends(projectInfo, transactionId); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Transaction not found"); + } + + [Fact] + public async Task ScanInvestmentSpends_WhenNoSpends_ReturnsEmptyLookup() + { + // Arrange + var projectInfo = TestDataBuilder.CreateProjectInfo().WithStages(3).Build(); + var transactionId = "test-tx-id"; + + // Create transaction info with unspent outputs + var txInfo = TestDataBuilder.CreateQueryTransaction() + .WithTransactionId(transactionId) + .AddOutput(0, 0) // Anchor output + .AddOutput(1, 0) // Anchor output + .AddOutput(2, 100000) // Stage 0 - unspent + .AddOutput(3, 100000) // Stage 1 - unspent + .AddOutput(4, 100000) // Stage 2 - unspent + .Build(); + + var txHex = CreateMockInvestmentTransactionHex(projectInfo, 3); + + _mockTransactionService + .Setup(x => x.GetTransactionInfoByIdAsync(transactionId)) + .ReturnsAsync(txInfo); + + _mockTransactionService + .Setup(x => x.GetTransactionHexByIdAsync(transactionId)) + .ReturnsAsync(txHex); + + // Act + var result = await _sut.ScanInvestmentSpends(projectInfo, transactionId); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.TransactionId.Should().Be(transactionId); + result.Value.ProjectIdentifier.Should().Be(projectInfo.ProjectIdentifier); + result.Value.EndOfProjectTransactionId.Should().BeNullOrEmpty(); + result.Value.RecoveryTransactionId.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task ScanInvestmentSpends_CallsTransactionServiceForInfo() + { + // Arrange + var projectInfo = TestDataBuilder.CreateProjectInfo().Build(); + var transactionId = "test-tx-for-verification"; + + _mockTransactionService + .Setup(x => x.GetTransactionInfoByIdAsync(transactionId)) + .ReturnsAsync((QueryTransaction?)null); + + // Act + await _sut.ScanInvestmentSpends(projectInfo, transactionId); + + // Assert + _mockTransactionService.Verify( + x => x.GetTransactionInfoByIdAsync(transactionId), + Times.Once); + } + + [Fact(Skip = "Requires properly constructed taproot transactions with valid scripts. The InvestorTransactionActions.DiscoverUsedScript method needs real taproot scripts to function properly.")] + public async Task ScanInvestmentSpends_WhenFounderSpent_ContinuesToNextStage() + { + // Arrange + var projectInfo = TestDataBuilder.CreateProjectInfo().WithStages(3).Build(); + var transactionId = "investment-tx-id"; + var founderSpentTxId = "founder-spent-tx-id"; + + // Create transaction info where stage 0 is spent by founder + var txInfo = TestDataBuilder.CreateQueryTransaction() + .WithTransactionId(transactionId) + .AddOutput(0, 0) + .AddOutput(1, 0) + .AddOutput(2, 100000, founderSpentTxId) // Stage 0 - spent by founder + .AddOutput(3, 100000) // Stage 1 - unspent + .AddOutput(4, 100000) // Stage 2 - unspent + .Build(); + + var txHex = CreateMockInvestmentTransactionHex(projectInfo, 3); + + _mockTransactionService + .Setup(x => x.GetTransactionInfoByIdAsync(transactionId)) + .ReturnsAsync(txInfo); + + _mockTransactionService + .Setup(x => x.GetTransactionHexByIdAsync(transactionId)) + .ReturnsAsync(txHex); + + // The spent transaction would have inputs indicating founder script was used + var founderSpentInfo = TestDataBuilder.CreateQueryTransaction() + .WithTransactionId(founderSpentTxId) + .AddInput(transactionId, 2, "founder-witness-script") + .Build(); + + _mockTransactionService + .Setup(x => x.GetTransactionInfoByIdAsync(founderSpentTxId)) + .ReturnsAsync(founderSpentInfo); + + // Act + var result = await _sut.ScanInvestmentSpends(projectInfo, transactionId); + + // Assert + result.IsSuccess.Should().BeTrue(); + // Founder spends don't set the recovery/end-of-project fields - processing continues + result.Value.RecoveryTransactionId.Should().BeNullOrEmpty(); + result.Value.EndOfProjectTransactionId.Should().BeNullOrEmpty(); + } + + #endregion + + #region Helper Methods + + /// + /// Creates a mock investment transaction with the specified number of taproot stage outputs. + /// + private Transaction CreateMockInvestmentTransaction(ProjectInfo projectInfo, int stageCount) + { + var transaction = _fixture.Network.CreateTransaction(); + + // Add anchor outputs (first 2 outputs) + transaction.Outputs.Add(new TxOut(Money.Zero, new Script())); + transaction.Outputs.Add(new TxOut(Money.Zero, new Script())); + + // Add stage outputs (taproot) + for (int i = 0; i < stageCount; i++) + { + // Create a taproot output (version 1, 32-byte pubkey) + var taprootPubKey = new byte[32]; + Random.Shared.NextBytes(taprootPubKey); + var taprootScript = new Script(new[] { (byte)0x51, (byte)0x20 }.Concat(taprootPubKey).ToArray()); + transaction.Outputs.Add(new TxOut(Money.Satoshis(100000), taprootScript)); + } + + return transaction; + } + + /// + /// Creates a hex representation of a mock investment transaction. + /// + private string CreateMockInvestmentTransactionHex(ProjectInfo projectInfo, int stageCount) + { + var transaction = CreateMockInvestmentTransaction(projectInfo, stageCount); + return transaction.ToHex(); + } + + #endregion +} diff --git a/src/Angor/Avalonia/Angor.Sdk.Tests/Shared/TestDataBuilder.cs b/src/Angor/Avalonia/Angor.Sdk.Tests/Shared/TestDataBuilder.cs new file mode 100644 index 000000000..7287c6770 --- /dev/null +++ b/src/Angor/Avalonia/Angor.Sdk.Tests/Shared/TestDataBuilder.cs @@ -0,0 +1,503 @@ +using Angor.Sdk.Funding.Projects.Domain; +using Angor.Sdk.Funding.Shared; +using Angor.Shared.Models; +using Blockcore.NBitcoin; +using Blockcore.NBitcoin.DataEncoders; +using Stage = Angor.Shared.Models.Stage; + +namespace Angor.Sdk.Tests.Shared; + +/// +/// Builder for creating test data objects with sensible defaults. +/// Supports fluent API for customization. +/// +public static class TestDataBuilder +{ + #region ProjectInfo Builder + + public static ProjectInfoBuilder CreateProjectInfo() => new(); + + public class ProjectInfoBuilder + { + private string _projectIdentifier = Encoders.Hex.EncodeData(RandomUtils.GetBytes(32)); + private string _founderKey = Encoders.Hex.EncodeData(RandomUtils.GetBytes(33)); + private string _founderRecoveryKey = Encoders.Hex.EncodeData(RandomUtils.GetBytes(33)); + private string _nostrPubKey = Encoders.Hex.EncodeData(RandomUtils.GetBytes(32)); + private long _targetAmount = Money.Coins(100).Satoshi; + private DateTime _startDate = DateTime.UtcNow; + private DateTime _expiryDate = DateTime.UtcNow.AddDays(30); + private DateTime _penaltyEndDate = DateTime.UtcNow.AddDays(45); + private int _stageCount = 3; + private ProjectType _projectType = ProjectType.Invest; + + public ProjectInfoBuilder WithProjectId(string projectId) + { + _projectIdentifier = projectId; + return this; + } + + public ProjectInfoBuilder WithFounderKey(string founderKey) + { + _founderKey = founderKey; + return this; + } + + public ProjectInfoBuilder WithFounderRecoveryKey(string founderRecoveryKey) + { + _founderRecoveryKey = founderRecoveryKey; + return this; + } + + public ProjectInfoBuilder WithTargetAmount(long satoshis) + { + _targetAmount = satoshis; + return this; + } + + public ProjectInfoBuilder WithStages(int count) + { + _stageCount = count; + return this; + } + + public ProjectInfoBuilder WithStartDate(DateTime date) + { + _startDate = date; + return this; + } + + public ProjectInfoBuilder WithExpiryDate(DateTime date) + { + _expiryDate = date; + return this; + } + + public ProjectInfoBuilder WithProjectType(ProjectType type) + { + _projectType = type; + return this; + } + + public ProjectInfo Build() + { + var stages = new List(); + var ratioPerStage = 100m / _stageCount; + + for (int i = 0; i < _stageCount; i++) + { + stages.Add(new Stage + { + AmountToRelease = ratioPerStage, + ReleaseDate = _startDate.AddDays((i + 1) * 10) + }); + } + + return new ProjectInfo + { + ProjectIdentifier = _projectIdentifier, + FounderKey = _founderKey, + FounderRecoveryKey = _founderRecoveryKey, + NostrPubKey = _nostrPubKey, + TargetAmount = _targetAmount, + StartDate = _startDate, + ExpiryDate = _expiryDate, + PenaltyDays = 15, + Stages = stages, + ProjectType = _projectType + }; + } + } + + #endregion + + #region Project Builder + + public static ProjectBuilder CreateProject() => new(); + + public class ProjectBuilder + { + private ProjectId _id = new(Encoders.Hex.EncodeData(RandomUtils.GetBytes(32))); + private string _name = "Test Project"; + private string _shortDescription = "A test project for unit testing"; + private string _founderKey = Encoders.Hex.EncodeData(RandomUtils.GetBytes(33)); + private string _founderRecoveryKey = Encoders.Hex.EncodeData(RandomUtils.GetBytes(33)); + private string _nostrPubKey = Encoders.Hex.EncodeData(RandomUtils.GetBytes(32)); + private long _targetAmount = Money.Coins(100).Satoshi; + private DateTime _startingDate = DateTime.UtcNow; + private DateTime _expiryDate = DateTime.UtcNow.AddDays(30); + private DateTime _endDate = DateTime.UtcNow.AddDays(30); + private TimeSpan _penaltyDuration = TimeSpan.FromDays(15); + private int _stageCount = 3; + private ProjectType _projectType = ProjectType.Invest; + private List? _dynamicStagePatterns; + + public ProjectBuilder WithId(string projectId) + { + _id = new ProjectId(projectId); + return this; + } + + public ProjectBuilder WithName(string name) + { + _name = name; + return this; + } + + public ProjectBuilder WithFounderKey(string founderKey) + { + _founderKey = founderKey; + return this; + } + + public ProjectBuilder WithFounderRecoveryKey(string founderRecoveryKey) + { + _founderRecoveryKey = founderRecoveryKey; + return this; + } + + public ProjectBuilder WithTargetAmount(long satoshis) + { + _targetAmount = satoshis; + return this; + } + + public ProjectBuilder WithStages(int count) + { + _stageCount = count; + return this; + } + + public ProjectBuilder WithStartingDate(DateTime date) + { + _startingDate = date; + return this; + } + + public ProjectBuilder WithProjectType(ProjectType type) + { + _projectType = type; + return this; + } + + public ProjectBuilder WithDynamicStagePatterns(List patterns) + { + _dynamicStagePatterns = patterns; + return this; + } + + public Project Build() + { + var stages = new List(); + var ratioPerStage = 1m / _stageCount; + + for (int i = 0; i < _stageCount; i++) + { + stages.Add(new Angor.Sdk.Funding.Projects.Domain.Stage + { + Index = i, + RatioOfTotal = ratioPerStage, + ReleaseDate = _startingDate.AddDays((i + 1) * 10) + }); + } + + return new Project + { + Id = _id, + Name = _name, + ShortDescription = _shortDescription, + FounderKey = _founderKey, + FounderRecoveryKey = _founderRecoveryKey, + NostrPubKey = _nostrPubKey, + TargetAmount = _targetAmount, + StartingDate = _startingDate, + ExpiryDate = _expiryDate, + EndDate = _endDate, + PenaltyDuration = _penaltyDuration, + Stages = stages, + ProjectType = _projectType, + DynamicStagePatterns = _dynamicStagePatterns ?? new List() + }; + } + } + + #endregion + + #region StageData Builder + + public static StageDataBuilder CreateStageData() => new(); + + public class StageDataBuilder + { + private int _stageIndex = 0; + private DateTime _stageDate = DateTime.UtcNow.AddDays(10); + private bool _isDynamic = false; + private List _items = new(); + + public StageDataBuilder WithStageIndex(int index) + { + _stageIndex = index; + return this; + } + + public StageDataBuilder WithStageDate(DateTime date) + { + _stageDate = date; + return this; + } + + public StageDataBuilder WithIsDynamic(bool isDynamic) + { + _isDynamic = isDynamic; + return this; + } + + public StageDataBuilder WithItems(List items) + { + _items = items; + return this; + } + + public StageDataBuilder AddItem(StageDataTrx item) + { + _items.Add(item); + return this; + } + + public StageData Build() + { + return new StageData + { + StageIndex = _stageIndex, + StageDate = _stageDate, + IsDynamic = _isDynamic, + Items = _items + }; + } + } + + #endregion + + #region StageDataTrx Builder + + public static StageDataTrxBuilder CreateStageDataTrx() => new(); + + public class StageDataTrxBuilder + { + private string _trxId = Encoders.Hex.EncodeData(RandomUtils.GetBytes(32)); + private int _outputIndex = 2; + private string _outputAddress = "tb1qtest"; + private int _stageIndex = 0; + private long _amount = Money.Coins(1).Satoshi; + private bool _isSpent = false; + private string _spentType = ""; + private string _investorPublicKey = Encoders.Hex.EncodeData(RandomUtils.GetBytes(33)); + + public StageDataTrxBuilder WithTrxId(string trxId) + { + _trxId = trxId; + return this; + } + + public StageDataTrxBuilder WithOutputIndex(int index) + { + _outputIndex = index; + return this; + } + + public StageDataTrxBuilder WithStageIndex(int index) + { + _stageIndex = index; + return this; + } + + public StageDataTrxBuilder WithAmount(long satoshis) + { + _amount = satoshis; + return this; + } + + public StageDataTrxBuilder AsSpent(string spentType) + { + _isSpent = true; + _spentType = spentType; + return this; + } + + public StageDataTrxBuilder WithInvestorPublicKey(string pubKey) + { + _investorPublicKey = pubKey; + return this; + } + + public StageDataTrx Build() + { + return new StageDataTrx + { + Trxid = _trxId, + Outputindex = _outputIndex, + OutputAddress = _outputAddress, + StageIndex = _stageIndex, + Amount = _amount, + IsSpent = _isSpent, + SpentType = _spentType, + InvestorPublicKey = _investorPublicKey + }; + } + } + + #endregion + + #region ProjectInvestment Builder + + public static ProjectInvestmentBuilder CreateProjectInvestment() => new(); + + public class ProjectInvestmentBuilder + { + private string _transactionId = Encoders.Hex.EncodeData(RandomUtils.GetBytes(32)); + private string _investorPublicKey = Encoders.Hex.EncodeData(RandomUtils.GetBytes(33)); + private long _totalAmount = Money.Coins(1).Satoshi; + + public ProjectInvestmentBuilder WithTransactionId(string txId) + { + _transactionId = txId; + return this; + } + + public ProjectInvestmentBuilder WithInvestorPublicKey(string pubKey) + { + _investorPublicKey = pubKey; + return this; + } + + public ProjectInvestmentBuilder WithTotalAmount(long satoshis) + { + _totalAmount = satoshis; + return this; + } + + public ProjectInvestment Build() + { + return new ProjectInvestment + { + TransactionId = _transactionId, + InvestorPublicKey = _investorPublicKey, + TotalAmount = _totalAmount + }; + } + } + + #endregion + + #region QueryTransaction Builder + + public static QueryTransactionBuilder CreateQueryTransaction() => new(); + + public class QueryTransactionBuilder + { + private string _transactionId = Encoders.Hex.EncodeData(RandomUtils.GetBytes(32)); + private List _outputs = new(); + private List _inputs = new(); + + public QueryTransactionBuilder WithTransactionId(string txId) + { + _transactionId = txId; + return this; + } + + public QueryTransactionBuilder AddOutput(int index, long balance, string? spentInTransaction = null) + { + _outputs.Add(new QueryTransactionOutput + { + Index = index, + Balance = balance, + SpentInTransaction = spentInTransaction + }); + return this; + } + + public QueryTransactionBuilder AddInput(string inputTxId, int inputIndex, string witScript = "") + { + _inputs.Add(new QueryTransactionInput + { + InputTransactionId = inputTxId, + InputIndex = inputIndex, + WitScript = witScript + }); + return this; + } + + public QueryTransaction Build() + { + return new QueryTransaction + { + TransactionId = _transactionId, + Outputs = _outputs, + Inputs = _inputs + }; + } + } + + #endregion + + #region InvestmentSpendingLookup Builder + + public static InvestmentSpendingLookupBuilder CreateInvestmentSpendingLookup() => new(); + + public class InvestmentSpendingLookupBuilder + { + private string _projectId = Encoders.Hex.EncodeData(RandomUtils.GetBytes(32)); + private string _transactionId = Encoders.Hex.EncodeData(RandomUtils.GetBytes(32)); + private string? _endOfProjectTxId; + private string? _recoveryTxId; + private string? _recoveryReleaseTxId; + private string? _unfundedReleaseTxId; + private long _amountInRecovery = 0; + + public InvestmentSpendingLookupBuilder WithProjectId(string projectId) + { + _projectId = projectId; + return this; + } + + public InvestmentSpendingLookupBuilder WithTransactionId(string txId) + { + _transactionId = txId; + return this; + } + + public InvestmentSpendingLookupBuilder WithEndOfProjectTransaction(string txId) + { + _endOfProjectTxId = txId; + return this; + } + + public InvestmentSpendingLookupBuilder WithRecoveryTransaction(string txId, long amount) + { + _recoveryTxId = txId; + _amountInRecovery = amount; + return this; + } + + public InvestmentSpendingLookupBuilder WithRecoveryReleaseTransaction(string txId) + { + _recoveryReleaseTxId = txId; + return this; + } + + public InvestmentSpendingLookup Build() + { + return new InvestmentSpendingLookup + { + ProjectIdentifier = _projectId, + TransactionId = _transactionId, + EndOfProjectTransactionId = _endOfProjectTxId, + RecoveryTransactionId = _recoveryTxId, + RecoveryReleaseTransactionId = _recoveryReleaseTxId, + UnfundedReleaseTransactionId = _unfundedReleaseTxId, + AmountInRecovery = _amountInRecovery + }; + } + } + + #endregion +} diff --git a/src/Angor/Avalonia/Angor.Sdk.Tests/Shared/TestNetworkFixture.cs b/src/Angor/Avalonia/Angor.Sdk.Tests/Shared/TestNetworkFixture.cs new file mode 100644 index 000000000..9866e0c3e --- /dev/null +++ b/src/Angor/Avalonia/Angor.Sdk.Tests/Shared/TestNetworkFixture.cs @@ -0,0 +1,97 @@ +using Angor.Shared; +using Angor.Shared.Protocol; +using Angor.Shared.Protocol.Scripts; +using Angor.Shared.Protocol.TransactionBuilders; +using Blockcore.NBitcoin.BIP32; +using Blockcore.Networks; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Angor.Sdk.Tests.Shared; + +/// +/// Shared fixture providing pre-configured network services and real crypto implementations. +/// Use with IClassFixture<TestNetworkFixture> to share across test classes. +/// +public class TestNetworkFixture +{ + public INetworkConfiguration NetworkConfiguration { get; } + public Network Network { get; } + public IDerivationOperations DerivationOperations { get; } + public IInvestorTransactionActions InvestorTransactionActions { get; } + public IFounderTransactionActions FounderTransactionActions { get; } + public IWalletOperations WalletOperations { get; } + public IHdOperations HdOperations { get; } + public IProjectScriptsBuilder ProjectScriptsBuilder { get; } + public IInvestmentScriptBuilder InvestmentScriptBuilder { get; } + public ITaprootScriptBuilder TaprootScriptBuilder { get; } + public ISpendingTransactionBuilder SpendingTransactionBuilder { get; } + public IInvestmentTransactionBuilder InvestmentTransactionBuilder { get; } + + /// + /// Standard test wallet words - use for deterministic test keys + /// + public const string TestWalletWords = "sorry poet adapt sister barely loud praise spray option oxygen hero surround"; + + /// + /// Alternative test wallet words - use for founder/investor separation + /// + public const string AlternateWalletWords = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + public TestNetworkFixture() + { + NetworkConfiguration = new Angor.Sdk.Common.NetworkConfiguration(); + NetworkConfiguration.SetNetwork(Angor.Shared.Networks.Networks.Bitcoin.Testnet()); + Network = NetworkConfiguration.GetNetwork(); + + HdOperations = new HdOperations(); + + DerivationOperations = new DerivationOperations( + HdOperations, + new NullLogger(), + NetworkConfiguration); + + TaprootScriptBuilder = new TaprootScriptBuilder(); + InvestmentScriptBuilder = new InvestmentScriptBuilder(new SeederScriptTreeBuilder()); + ProjectScriptsBuilder = new ProjectScriptsBuilder(DerivationOperations); + + SpendingTransactionBuilder = new SpendingTransactionBuilder( + NetworkConfiguration, + ProjectScriptsBuilder, + InvestmentScriptBuilder); + + InvestmentTransactionBuilder = new InvestmentTransactionBuilder( + NetworkConfiguration, + ProjectScriptsBuilder, + InvestmentScriptBuilder, + TaprootScriptBuilder); + + InvestorTransactionActions = new InvestorTransactionActions( + new NullLogger(), + InvestmentScriptBuilder, + ProjectScriptsBuilder, + SpendingTransactionBuilder, + InvestmentTransactionBuilder, + TaprootScriptBuilder, + NetworkConfiguration); + + FounderTransactionActions = new FounderTransactionActions( + new NullLogger(), + NetworkConfiguration, + ProjectScriptsBuilder, + InvestmentScriptBuilder, + TaprootScriptBuilder); + } + + /// + /// Creates WalletOperations with a mock indexer service. + /// Use this when you need to control UTXO retrieval. + /// + public IWalletOperations CreateWalletOperations(Angor.Shared.Services.IIndexerService mockIndexerService) + { + return new WalletOperations( + mockIndexerService, + HdOperations, + new NullLogger(), + NetworkConfiguration); + } +}