diff --git a/src/design/App.Test.Integration/FundAndRecoverTest.cs b/src/design/App.Test.Integration/FundAndRecoverTest.cs
index e6629eafd..b03cece43 100644
--- a/src/design/App.Test.Integration/FundAndRecoverTest.cs
+++ b/src/design/App.Test.Integration/FundAndRecoverTest.cs
@@ -87,6 +87,8 @@ public async Task FullFundAndRecoverFlow()
// ──────────────────────────────────────────────────────────────
TestHelpers.Log("[STEP 0] Booting app with ShellView...");
var window = TestHelpers.CreateShellWindow();
+ try
+ {
var shellVm = window.GetShellViewModel();
TestHelpers.Log("[STEP 0] App booted. ShellView created, ShellViewModel ready.");
@@ -945,9 +947,13 @@ public async Task FullFundAndRecoverFlow()
$"InPenalty={targetInvestment.RecoveryState.HasSpendableItemsInPenalty}, " +
$"ActionKey={targetInvestment.RecoveryState.ActionKey}");
- // Cleanup: close window
- window.Close();
TestHelpers.Log("========== FullFundAndRecoverFlow PASSED ==========");
+ }
+ finally
+ {
+ window.Close();
+ Dispatcher.UIThread.RunJobs();
+ }
}
}
diff --git a/src/design/App.Test.Integration/InvestAndRecoverTest.cs b/src/design/App.Test.Integration/InvestAndRecoverTest.cs
index 2df723de7..f74812f4f 100644
--- a/src/design/App.Test.Integration/InvestAndRecoverTest.cs
+++ b/src/design/App.Test.Integration/InvestAndRecoverTest.cs
@@ -45,6 +45,8 @@ public async Task FullInvestAndRecoverFlow()
TestHelpers.Log($"[STEP 0] Investment amount: {investmentAmountBtc} BTC");
var window = TestHelpers.CreateShellWindow();
+ try
+ {
var shellVm = window.GetShellViewModel();
TestHelpers.Log("[STEP 1] Wiping existing data...");
@@ -586,8 +588,13 @@ public async Task FullInvestAndRecoverFlow()
targetInvestment.ShowSuccessModal.Should().BeTrue(
$"Recovery operation '{actionKey}' should succeed and show the success modal");
- window.Close();
TestHelpers.Log("========== FullInvestAndRecoverFlow PASSED ==========");
+ }
+ finally
+ {
+ window.Close();
+ Dispatcher.UIThread.RunJobs();
+ }
}
private async Task EnsureWalletHasFeeFunds(Window window, string walletId, string context)
diff --git a/src/design/App.Test.Integration/InvestModalsViewFixesTest.cs b/src/design/App.Test.Integration/InvestModalsViewFixesTest.cs
index 39411d151..b185c5b01 100644
--- a/src/design/App.Test.Integration/InvestModalsViewFixesTest.cs
+++ b/src/design/App.Test.Integration/InvestModalsViewFixesTest.cs
@@ -133,11 +133,8 @@ await PumpUntilAsync(
"errors must be tagged with the step that produced them");
}
- // Liquid + Import tabs are visual stubs — switching to them does not crash and surfaces a known label.
- Log("[7] Stub tabs do not crash...");
- vm.SelectNetworkTab(NetworkTab.Liquid);
- vm.SelectedNetworkTab.Should().Be(NetworkTab.Liquid);
- vm.InvoiceFieldLabel.Should().Be("Liquid Address");
+ // Import tab is a visual stub — switching to it does not crash and surfaces a known label.
+ Log("[7] Stub tab does not crash...");
vm.SelectNetworkTab(NetworkTab.Import);
vm.SelectedNetworkTab.Should().Be(NetworkTab.Import);
vm.InvoiceFieldLabel.Should().Be("Imported Invoice");
diff --git a/src/design/App.Test.Integration/OneClickInvestFundersTest.cs b/src/design/App.Test.Integration/OneClickInvestFundersTest.cs
new file mode 100644
index 000000000..e8e3f3d05
--- /dev/null
+++ b/src/design/App.Test.Integration/OneClickInvestFundersTest.cs
@@ -0,0 +1,642 @@
+using Angor.Sdk.Funding.Investor;
+using Angor.Sdk.Wallet.Application;
+using App.Test.Integration.Helpers;
+using App.UI.Sections.FindProjects;
+using App.UI.Sections.Funders;
+using App.UI.Sections.Portfolio;
+using App.UI.Shared;
+using App.UI.Shared.Services;
+using Avalonia.Headless.XUnit;
+using Avalonia.Threading;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace App.Test.Integration;
+
+///
+/// Full headless integration test for the 1-click fund flow triggered from the Funders section.
+/// Focuses on Fund-type project behavior:
+/// - Threshold check (auto-approve below, founder signatures above)
+/// - PatternIndex is set for Fund projects
+/// - PayWithWallet path (direct wallet payment)
+/// - Success messages are Fund-specific
+/// - FundersVM → InvestPageVM integration
+///
+public class OneClickInvestFundersTest
+{
+ [AvaloniaFact]
+ public void FundFlow_OpenFromFunders_CreatesFundTypeProject()
+ {
+ using var profileScope = TestProfileScope.For(nameof(OneClickInvestFundersTest));
+ Log("========== STARTING fund flow project model test ==========");
+
+ var window = TestHelpers.CreateShellWindow();
+ var services = global::App.App.Services;
+
+ var fundersVm = services.GetRequiredService();
+ var currencyService = services.GetRequiredService();
+
+ var sig = CreateTestSignatureRequest();
+ fundersVm.OpenInvestFlow(sig);
+ Dispatcher.UIThread.RunJobs();
+
+ var vm = fundersVm.InvestPageViewModel;
+ vm.Should().NotBeNull("OpenInvestFlow should create an InvestPageViewModel");
+
+ // Fund-specific project model assertions
+ vm!.Project.ProjectType.Should().Be("Fund", "funders section creates Fund-type projects");
+ vm.Project.ProjectId.Should().Be(sig.ProjectIdentifier);
+ vm.Project.ProjectName.Should().Be(sig.ProjectTitle);
+ vm.Project.CurrencySymbol.Should().Be(currencyService.Symbol);
+
+ // Fund-type behavior flags
+ vm.IsSubscription.Should().BeFalse("Fund is not a subscription");
+ vm.IsNotSubscription.Should().BeTrue();
+
+ // Fund-type terminology
+ vm.ScheduleTitle.Should().Be("Release Schedule");
+ vm.StageRowPrefix.Should().Be("Stage");
+ vm.SuccessButtonText.Should().Be("View My Fundings");
+
+ window.Close();
+ Log("========== Fund flow project model test PASSED ==========");
+ }
+
+ [AvaloniaFact]
+ public void FundFlow_SubmitAdvancesToWalletSelector()
+ {
+ using var profileScope = TestProfileScope.For(nameof(OneClickInvestFundersTest) + "_Submit");
+ Log("========== STARTING fund flow submit test ==========");
+
+ var window = TestHelpers.CreateShellWindow();
+ var services = global::App.App.Services;
+
+ var fundersVm = services.GetRequiredService();
+
+ var sig = CreateTestSignatureRequest();
+ fundersVm.OpenInvestFlow(sig);
+ Dispatcher.UIThread.RunJobs();
+
+ var vm = fundersVm.InvestPageViewModel!;
+ vm.CurrentScreen.Should().Be(InvestScreen.InvestForm);
+
+ // Below minimum — cannot submit
+ vm.InvestmentAmount = "0.0001";
+ Dispatcher.UIThread.RunJobs();
+ vm.CanSubmit.Should().BeFalse("0.0001 is below minimum investment threshold");
+
+ vm.Submit();
+ Dispatcher.UIThread.RunJobs();
+ vm.CurrentScreen.Should().Be(InvestScreen.InvestForm, "submit rejected when CanSubmit is false");
+
+ // At minimum — can submit
+ vm.InvestmentAmount = "0.001";
+ Dispatcher.UIThread.RunJobs();
+ vm.CanSubmit.Should().BeTrue("0.001 meets the minimum investment threshold");
+
+ vm.Submit();
+ Dispatcher.UIThread.RunJobs();
+ vm.CurrentScreen.Should().Be(InvestScreen.WalletSelector,
+ "successful submit advances to wallet selector for fund payment");
+
+ window.Close();
+ Log("========== Fund flow submit test PASSED ==========");
+ }
+
+ [AvaloniaFact]
+ public async Task FundFlow_PayWithWallet_NoWalletSelected_ShowsError()
+ {
+ using var profileScope = TestProfileScope.For(nameof(OneClickInvestFundersTest) + "_NoWallet");
+ Log("========== STARTING fund flow no-wallet error test ==========");
+
+ var window = TestHelpers.CreateShellWindow();
+ var services = global::App.App.Services;
+
+ var fundersVm = services.GetRequiredService();
+
+ var sig = CreateTestSignatureRequest();
+ fundersVm.OpenInvestFlow(sig);
+ Dispatcher.UIThread.RunJobs();
+
+ var vm = fundersVm.InvestPageViewModel!;
+ vm.InvestmentAmount = "0.001";
+ Dispatcher.UIThread.RunJobs();
+ vm.Submit();
+ Dispatcher.UIThread.RunJobs();
+
+ // Attempt to pay without selecting a wallet
+ vm.SelectedWallet.Should().BeNull("no wallet selected yet");
+ vm.PayWithWallet();
+ await PumpUntilAsync(() => vm.ErrorMessage != null, TimeSpan.FromSeconds(5));
+
+ vm.ErrorMessage.Should().Be("No wallet selected.",
+ "PayWithWallet without wallet selection should show clear error");
+ vm.IsProcessing.Should().BeFalse("should not be processing after immediate error");
+
+ window.Close();
+ Log("========== Fund flow no-wallet error test PASSED ==========");
+ }
+
+ [AvaloniaFact]
+ public async Task FundFlow_PayWithWallet_BuildsDraft_WithPatternIndex()
+ {
+ using var profileScope = TestProfileScope.For(nameof(OneClickInvestFundersTest) + "_Draft");
+ Log("========== STARTING fund flow draft building test ==========");
+
+ var window = TestHelpers.CreateShellWindow();
+ var services = global::App.App.Services;
+
+ var fundersVm = services.GetRequiredService();
+ var walletContext = services.GetRequiredService();
+
+ var sig = CreateTestSignatureRequest();
+ fundersVm.OpenInvestFlow(sig);
+ Dispatcher.UIThread.RunJobs();
+
+ var vm = fundersVm.InvestPageViewModel!;
+ vm.InvestmentAmount = "0.001";
+ Dispatcher.UIThread.RunJobs();
+ vm.Submit();
+ Dispatcher.UIThread.RunJobs();
+
+ // Select a wallet if available
+ var wallet = walletContext.Wallets.FirstOrDefault();
+ if (wallet == null)
+ {
+ Log(" No wallet available — skipping draft build (expected in CI without wallet)");
+ window.Close();
+ return;
+ }
+
+ vm.SelectWallet(wallet);
+ Dispatcher.UIThread.RunJobs();
+ vm.SelectedWallet.Should().NotBeNull();
+ vm.HasSelectedWallet.Should().BeTrue();
+ vm.PayButtonText.Should().Contain(wallet.Name);
+
+ // Trigger pay — will attempt to build draft with Fund-specific patternIndex
+ vm.PayWithWallet();
+ await PumpUntilAsync(
+ () => !string.IsNullOrEmpty(vm.ErrorMessage) || vm.CurrentScreen == InvestScreen.Success || !vm.IsProcessing,
+ TimeSpan.FromSeconds(15));
+
+ Log($" ErrorMessage: '{vm.ErrorMessage}'");
+ Log($" CurrentScreen: {vm.CurrentScreen}");
+ Log($" PaymentStatusText: '{vm.PaymentStatusText}'");
+
+ // Regardless of outcome (success or SDK error), verify Fund-specific behavior:
+ // - No raw exceptions leaked
+ if (vm.ErrorMessage != null)
+ {
+ vm.ErrorMessage.Should().NotContain("NullReferenceException");
+ vm.ErrorMessage.Should().NotContain("Parameter 'key'");
+ }
+
+ // If success, verify Fund-specific success state
+ if (vm.CurrentScreen == InvestScreen.Success)
+ {
+ vm.SuccessTitle.Should().BeOneOf("Funding Successful", "Funding Pending Approval",
+ "Fund type should show fund-specific success title");
+ vm.SuccessButtonText.Should().Be("View My Fundings");
+ }
+
+ window.Close();
+ Log("========== Fund flow draft building test PASSED ==========");
+ }
+
+ [AvaloniaFact]
+ public async Task FundFlow_ThresholdCheck_DeterminesApprovalPath()
+ {
+ using var profileScope = TestProfileScope.For(nameof(OneClickInvestFundersTest) + "_Threshold");
+ Log("========== STARTING fund flow threshold check test ==========");
+
+ var window = TestHelpers.CreateShellWindow();
+ var services = global::App.App.Services;
+
+ var fundersVm = services.GetRequiredService();
+ var walletContext = services.GetRequiredService();
+
+ // Create a signature with a known threshold (1 BTC = 100_000_000 sats)
+ var sig = new SignatureRequestViewModel
+ {
+ Id = 10002,
+ ProjectTitle = "High Threshold Fund",
+ ProjectIdentifier = "high-threshold-fund-project",
+ Amount = "1.0000",
+ Currency = "TBTC",
+ AmountSats = 100_000_000,
+ Status = "waiting",
+ Npub = "npub1threshold",
+ EventId = "event-threshold",
+ FounderWalletId = "wallet-threshold",
+ InvestmentTransactionHex = "02000000",
+ InvestorNostrPubKey = "investor-threshold"
+ };
+
+ fundersVm.OpenInvestFlow(sig);
+ Dispatcher.UIThread.RunJobs();
+
+ var vm = fundersVm.InvestPageViewModel!;
+
+ // Set amount below typical threshold (0.001 BTC = 100,000 sats)
+ vm.InvestmentAmount = "0.001";
+ Dispatcher.UIThread.RunJobs();
+ vm.Submit();
+ Dispatcher.UIThread.RunJobs();
+
+ var wallet = walletContext.Wallets.FirstOrDefault();
+ if (wallet == null)
+ {
+ Log(" No wallet available — verifying threshold logic statically");
+ // Verify that Fund projects trigger threshold check in the payment path
+ // (the actual SDK call requires a wallet, but we can verify the VM setup)
+ vm.Project.ProjectType.Should().Be("Fund",
+ "Fund projects use threshold check to determine auto-approve vs. signatures");
+ window.Close();
+ return;
+ }
+
+ vm.SelectWallet(wallet);
+ Dispatcher.UIThread.RunJobs();
+
+ vm.PayWithWallet();
+ await PumpUntilAsync(
+ () => !string.IsNullOrEmpty(vm.ErrorMessage) ||
+ vm.CurrentScreen == InvestScreen.Success ||
+ !vm.IsProcessing,
+ TimeSpan.FromSeconds(15));
+
+ Log($" IsAutoApproved: {vm.IsAutoApproved}");
+ Log($" CurrentScreen: {vm.CurrentScreen}");
+ Log($" ErrorMessage: '{vm.ErrorMessage}'");
+
+ // If we reached success, verify the threshold determined the path
+ if (vm.CurrentScreen == InvestScreen.Success)
+ {
+ if (vm.IsAutoApproved)
+ {
+ vm.SuccessTitle.Should().Be("Funding Successful",
+ "below-threshold Fund investment should show auto-approved title");
+ vm.SuccessDescription.Should().Contain("published successfully");
+ }
+ else
+ {
+ vm.SuccessTitle.Should().Be("Funding Pending Approval",
+ "above-threshold Fund investment should show pending approval title");
+ vm.SuccessDescription.Should().Contain("pending founder approval");
+ }
+ }
+
+ window.Close();
+ Log("========== Fund flow threshold check test PASSED ==========");
+ }
+
+ [AvaloniaFact]
+ public async Task FundFlow_InvoicePath_UsesThresholdCheck()
+ {
+ using var profileScope = TestProfileScope.For(nameof(OneClickInvestFundersTest) + "_Invoice");
+ Log("========== STARTING fund flow invoice path threshold test ==========");
+
+ var window = TestHelpers.CreateShellWindow();
+ var services = global::App.App.Services;
+
+ var fundersVm = services.GetRequiredService();
+
+ var sig = CreateTestSignatureRequest();
+ fundersVm.OpenInvestFlow(sig);
+ Dispatcher.UIThread.RunJobs();
+
+ var vm = fundersVm.InvestPageViewModel!;
+ vm.InvestmentAmount = "0.001";
+ Dispatcher.UIThread.RunJobs();
+
+ // Enter invoice screen (on-chain default)
+ vm.ShowInvoice();
+ Dispatcher.UIThread.RunJobs();
+
+ vm.CurrentScreen.Should().Be(InvestScreen.Invoice);
+ vm.IsProcessing.Should().BeTrue("on-chain monitoring starts");
+
+ // Let on-chain flow attempt to run
+ await PumpUntilAsync(
+ () => !string.IsNullOrEmpty(vm.ErrorMessage) || vm.OnChainAddress != null,
+ TimeSpan.FromSeconds(10));
+
+ Log($" OnChainAddress: '{vm.OnChainAddress}'");
+ Log($" ErrorMessage: '{vm.ErrorMessage}'");
+
+ // If we got an address, the flow will eventually call CompleteInvestmentAfterFundingAsync
+ // which includes the Fund-type threshold check. Verify the VM state is consistent.
+ if (vm.OnChainAddress != null)
+ {
+ vm.OnChainAddress.Should().NotBeEmpty("generated address should not be blank");
+ vm.InvoiceString.Should().Be(vm.OnChainAddress,
+ "InvoiceString should show the generated address");
+ vm.QrCodeContent.Should().Be(vm.OnChainAddress,
+ "QR code should display the address");
+ }
+
+ // Regardless of SDK availability, verify Fund-type specifics:
+ // The CompleteInvestmentAfterFundingAsync uses patternIndex for Fund projects
+ vm.Project.ProjectType.Should().Be("Fund");
+
+ window.Close();
+ Log("========== Fund flow invoice path threshold test PASSED ==========");
+ }
+
+ [AvaloniaFact]
+ public void FundFlow_QuickAmounts_WorkForFundType()
+ {
+ using var profileScope = TestProfileScope.For(nameof(OneClickInvestFundersTest) + "_Quick");
+ Log("========== STARTING fund flow quick amounts test ==========");
+
+ var window = TestHelpers.CreateShellWindow();
+ var services = global::App.App.Services;
+
+ var fundersVm = services.GetRequiredService();
+ var currencyService = services.GetRequiredService();
+
+ var sig = CreateTestSignatureRequest();
+ fundersVm.OpenInvestFlow(sig);
+ Dispatcher.UIThread.RunJobs();
+
+ var vm = fundersVm.InvestPageViewModel!;
+
+ // Verify quick amounts are available for Fund type (not subscription)
+ vm.QuickAmounts.Should().HaveCount(4, "Fund projects show 4 quick amount options");
+ vm.QuickAmounts[0].Amount.Should().Be(0.001);
+ vm.QuickAmounts[1].Amount.Should().Be(0.01);
+ vm.QuickAmounts[2].Amount.Should().Be(0.1);
+ vm.QuickAmounts[3].Amount.Should().Be(0.5);
+
+ // Each quick amount label should use the correct currency symbol
+ foreach (var option in vm.QuickAmounts)
+ {
+ option.Label.Should().Be(currencyService.Symbol);
+ }
+
+ // Select a quick amount
+ vm.SelectQuickAmount(0.01);
+ Dispatcher.UIThread.RunJobs();
+
+ vm.InvestmentAmount.Should().Be("0.01");
+ vm.SelectedQuickAmount.Should().Be(0.01);
+ vm.FormattedAmount.Should().Be("0.01000000");
+ vm.CanSubmit.Should().BeTrue();
+
+ // Verify totals include fees
+ vm.TotalAmount.Should().NotBe($"0.00000000 {currencyService.Symbol}",
+ "total should include amount + fees");
+ vm.AngorFeeAmount.Should().NotStartWith("0.00000000",
+ "Angor fee should be calculated on the investment amount");
+
+ window.Close();
+ Log("========== Fund flow quick amounts test PASSED ==========");
+ }
+
+ [AvaloniaFact]
+ public void FundFlow_WalletSelection_UpdatesPayButton()
+ {
+ using var profileScope = TestProfileScope.For(nameof(OneClickInvestFundersTest) + "_Wallet");
+ Log("========== STARTING fund flow wallet selection test ==========");
+
+ var window = TestHelpers.CreateShellWindow();
+ var services = global::App.App.Services;
+
+ var fundersVm = services.GetRequiredService();
+ var walletContext = services.GetRequiredService();
+
+ var sig = CreateTestSignatureRequest();
+ fundersVm.OpenInvestFlow(sig);
+ Dispatcher.UIThread.RunJobs();
+
+ var vm = fundersVm.InvestPageViewModel!;
+ vm.InvestmentAmount = "0.001";
+ Dispatcher.UIThread.RunJobs();
+ vm.Submit();
+ Dispatcher.UIThread.RunJobs();
+
+ vm.CurrentScreen.Should().Be(InvestScreen.WalletSelector);
+
+ // Before wallet selection
+ vm.HasSelectedWallet.Should().BeFalse();
+ vm.PayButtonText.Should().Be("Choose Wallet");
+
+ var wallet = walletContext.Wallets.FirstOrDefault();
+ if (wallet == null)
+ {
+ Log(" No wallet available — verifying unselected state only");
+ window.Close();
+ return;
+ }
+
+ // After wallet selection
+ vm.SelectWallet(wallet);
+ Dispatcher.UIThread.RunJobs();
+
+ vm.HasSelectedWallet.Should().BeTrue();
+ vm.SelectedWallet.Should().Be(wallet);
+ vm.PayButtonText.Should().Contain(wallet.Name,
+ "pay button should show selected wallet name");
+ wallet.IsSelected.Should().BeTrue("selected wallet should be marked");
+
+ window.Close();
+ Log("========== Fund flow wallet selection test PASSED ==========");
+ }
+
+ [AvaloniaFact]
+ public void FundFlow_OpenInvestFlow_WithEmptyProjectId_DoesNotCreate()
+ {
+ using var profileScope = TestProfileScope.For(nameof(OneClickInvestFundersTest) + "_NoId");
+ Log("========== STARTING fund flow empty project ID test ==========");
+
+ var window = TestHelpers.CreateShellWindow();
+ var services = global::App.App.Services;
+
+ var fundersVm = services.GetRequiredService();
+
+ var sig = new SignatureRequestViewModel
+ {
+ Id = 99998,
+ ProjectTitle = "No ID Project",
+ ProjectIdentifier = "",
+ Amount = "0.1000",
+ Status = "waiting"
+ };
+
+ fundersVm.OpenInvestFlow(sig);
+ Dispatcher.UIThread.RunJobs();
+
+ fundersVm.InvestPageViewModel.Should().BeNull(
+ "OpenInvestFlow should not create a VM when ProjectIdentifier is empty");
+
+ window.Close();
+ Log("========== Fund flow empty project ID test PASSED ==========");
+ }
+
+ [AvaloniaFact]
+ public void FundFlow_CloseAndReopen_FreshState()
+ {
+ using var profileScope = TestProfileScope.For(nameof(OneClickInvestFundersTest) + "_Reopen");
+ Log("========== STARTING fund flow close and reopen test ==========");
+
+ var window = TestHelpers.CreateShellWindow();
+ var services = global::App.App.Services;
+
+ var fundersVm = services.GetRequiredService();
+
+ var sig = CreateTestSignatureRequest();
+ fundersVm.OpenInvestFlow(sig);
+ Dispatcher.UIThread.RunJobs();
+
+ var firstVm = fundersVm.InvestPageViewModel!;
+ firstVm.InvestmentAmount = "0.05";
+ Dispatcher.UIThread.RunJobs();
+ firstVm.Submit();
+ Dispatcher.UIThread.RunJobs();
+ firstVm.CurrentScreen.Should().Be(InvestScreen.WalletSelector);
+
+ // Close the flow
+ fundersVm.CloseInvestFlow();
+ Dispatcher.UIThread.RunJobs();
+ fundersVm.InvestPageViewModel.Should().BeNull();
+
+ // Reopen — should be a fresh instance at InvestForm
+ fundersVm.OpenInvestFlow(sig);
+ Dispatcher.UIThread.RunJobs();
+
+ var secondVm = fundersVm.InvestPageViewModel!;
+ secondVm.Should().NotBeSameAs(firstVm, "reopen should create a new instance");
+ secondVm.CurrentScreen.Should().Be(InvestScreen.InvestForm,
+ "new instance starts fresh at InvestForm");
+ secondVm.InvestmentAmount.Should().BeEmpty("new instance has no amount set");
+ secondVm.IsProcessing.Should().BeFalse();
+ secondVm.ErrorMessage.Should().BeNull();
+
+ window.Close();
+ Log("========== Fund flow close and reopen test PASSED ==========");
+ }
+
+ [AvaloniaFact]
+ public async Task FundFlow_PortfolioDeduplication()
+ {
+ using var profileScope = TestProfileScope.For(nameof(OneClickInvestFundersTest) + "_Dedup");
+ Log("========== STARTING fund flow portfolio deduplication test ==========");
+
+ var window = TestHelpers.CreateShellWindow();
+ var services = global::App.App.Services;
+
+ var portfolioVm = services.GetRequiredService();
+ var fundersVm = services.GetRequiredService();
+
+ var sig = CreateTestSignatureRequest();
+ fundersVm.OpenInvestFlow(sig);
+ Dispatcher.UIThread.RunJobs();
+
+ var vm = fundersVm.InvestPageViewModel!;
+ vm.InvestmentAmount = "0.001";
+ Dispatcher.UIThread.RunJobs();
+
+ // Simulate completed fund → add to portfolio
+ var initialCount = portfolioVm.Investments.Count;
+ vm.AddToPortfolio();
+ Dispatcher.UIThread.RunJobs();
+
+ portfolioVm.Investments.Count.Should().Be(initialCount + 1,
+ "first add should insert a new entry");
+
+ var entry = portfolioVm.Investments[0];
+ entry.ProjectIdentifier.Should().Be(sig.ProjectIdentifier);
+
+ // Duplicate add (e.g. SDK returns same investment on refresh)
+ vm.AddToPortfolio();
+ Dispatcher.UIThread.RunJobs();
+
+ portfolioVm.Investments.Count.Should().Be(initialCount + 1,
+ "second add of the same project must NOT create a duplicate");
+
+ window.Close();
+ Log("========== Fund flow portfolio deduplication test PASSED ==========");
+ }
+
+ [AvaloniaFact]
+ public void FundFlow_SuccessMessages_MatchFundType()
+ {
+ using var profileScope = TestProfileScope.For(nameof(OneClickInvestFundersTest) + "_Success");
+ Log("========== STARTING fund flow success messages test ==========");
+
+ var window = TestHelpers.CreateShellWindow();
+ var services = global::App.App.Services;
+
+ var fundersVm = services.GetRequiredService();
+
+ var sig = CreateTestSignatureRequest();
+ fundersVm.OpenInvestFlow(sig);
+ Dispatcher.UIThread.RunJobs();
+
+ var vm = fundersVm.InvestPageViewModel!;
+ vm.InvestmentAmount = "0.01";
+ Dispatcher.UIThread.RunJobs();
+
+ // Test auto-approved path (below threshold)
+ vm.IsAutoApproved = true;
+ Dispatcher.UIThread.RunJobs();
+
+ vm.SuccessTitle.Should().Be("Funding Successful");
+ vm.SuccessDescription.Should().Contain("published successfully");
+ vm.SuccessDescription.Should().Contain("0.01000000");
+ vm.SuccessDescription.Should().Contain(sig.ProjectTitle);
+ vm.SuccessButtonText.Should().Be("View My Fundings");
+
+ // Test pending approval path (above threshold)
+ vm.IsAutoApproved = false;
+ Dispatcher.UIThread.RunJobs();
+
+ vm.SuccessTitle.Should().Be("Funding Pending Approval");
+ vm.SuccessDescription.Should().Contain("pending founder approval");
+ vm.SuccessDescription.Should().Contain(sig.ProjectTitle);
+
+ window.Close();
+ Log("========== Fund flow success messages test PASSED ==========");
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // Helpers
+ // ═══════════════════════════════════════════════════════════════════
+
+ private static SignatureRequestViewModel CreateTestSignatureRequest() => new()
+ {
+ Id = 10001,
+ ProjectTitle = "Headless Funder Test Project",
+ ProjectIdentifier = "headless-funder-test-project",
+ Amount = "0.0010",
+ Currency = "TBTC",
+ AmountSats = 100_000,
+ Date = "Apr 20, 2026",
+ Time = "12:00",
+ Status = "waiting",
+ Npub = "npub1headlesstestkey",
+ EventId = "event-headless-test",
+ FounderWalletId = "wallet-headless-test",
+ InvestmentTransactionHex = "02000000deadbeef",
+ InvestorNostrPubKey = "investor-headless-pub"
+ };
+
+ private static async Task PumpUntilAsync(Func condition, TimeSpan timeout)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ Dispatcher.UIThread.RunJobs();
+ if (condition()) return;
+ await Task.Delay(50);
+ }
+ Dispatcher.UIThread.RunJobs();
+ }
+
+ private static void Log(string message)
+ {
+ Console.WriteLine($"[{DateTime.UtcNow:HH:mm:ss.fff}] {message}");
+ }
+}
diff --git a/src/design/App.Test.Integration/OneClickInvestLightningTest.cs b/src/design/App.Test.Integration/OneClickInvestLightningTest.cs
index 4d4e5c508..b737daf13 100644
--- a/src/design/App.Test.Integration/OneClickInvestLightningTest.cs
+++ b/src/design/App.Test.Integration/OneClickInvestLightningTest.cs
@@ -158,17 +158,12 @@ await PumpUntilAsync(
vm.IsLightningTab.Should().BeTrue();
// ── Step 6: Stub tabs don't crash ──
- Log("[6] Stub tabs (Liquid, Import) don't crash...");
- vm.SelectNetworkTab(NetworkTab.Liquid);
- Dispatcher.UIThread.RunJobs();
- vm.SelectedNetworkTab.Should().Be(NetworkTab.Liquid);
- vm.InvoiceFieldLabel.Should().Be("Liquid Address");
- vm.IsProcessing.Should().BeFalse("stub tabs don't start async work");
-
+ Log("[6] Stub tab (Import) doesn't crash...");
vm.SelectNetworkTab(NetworkTab.Import);
Dispatcher.UIThread.RunJobs();
vm.SelectedNetworkTab.Should().Be(NetworkTab.Import);
vm.InvoiceFieldLabel.Should().Be("Imported Invoice");
+ vm.IsProcessing.Should().BeFalse("stub tabs don't start async work");
// ── Step 7: CloseModal full reset ──
Log("[7] CloseModal resets all Lightning state...");
diff --git a/src/design/App.Test.Integration/TestAppBuilder.cs b/src/design/App.Test.Integration/TestAppBuilder.cs
index 8db77e7a8..f7901fa06 100644
--- a/src/design/App.Test.Integration/TestAppBuilder.cs
+++ b/src/design/App.Test.Integration/TestAppBuilder.cs
@@ -1,10 +1,13 @@
using System.Reflection;
+using Angor.Shared;
+using Angor.Shared.Models;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Markup.Xaml.Styling;
using App.Composition;
using App.Test.Integration.Helpers;
+using Microsoft.Extensions.DependencyInjection;
using Projektanker.Icons.Avalonia;
using Projektanker.Icons.Avalonia.FontAwesome;
@@ -33,6 +36,15 @@ private static void ConfigureServices(string profileName)
var services = CompositionRoot.BuildServiceProvider(profileName, enableConsoleLogging: true);
var prop = typeof(global::App.App).GetProperty("Services", BindingFlags.Public | BindingFlags.Static)!;
prop.SetValue(null, services);
+
+ // Override relays with the test relay for reliable test execution
+ var networkStorage = services.GetRequiredService();
+ var settings = networkStorage.GetSettings();
+ settings.Relays = new List
+ {
+ new() { Name = "Test Relay", Url = "wss://test.thedude.cloud", IsPrimary = true }
+ };
+ networkStorage.SetSettings(settings);
}
}
diff --git a/src/design/App/UI/Sections/FindProjects/InvestModalsView.axaml b/src/design/App/UI/Sections/FindProjects/InvestModalsView.axaml
index acbf19927..82d951912 100644
--- a/src/design/App/UI/Sections/FindProjects/InvestModalsView.axaml
+++ b/src/design/App/UI/Sections/FindProjects/InvestModalsView.axaml
@@ -299,8 +299,7 @@
+ Click handlers in code-behind read the Border name and call Vm.SelectNetworkTab(...). -->
8
SelectedNetworkTab == NetworkTab.OnChain;
public bool IsLightningTab => SelectedNetworkTab == NetworkTab.Lightning;
- public bool IsLiquidTab => SelectedNetworkTab == NetworkTab.Liquid;
public bool IsImportTab => SelectedNetworkTab == NetworkTab.Import;
/// Header above the address/invoice text — flips with the active tab.
public string InvoiceFieldLabel => SelectedNetworkTab switch
{
NetworkTab.Lightning => "Lightning Invoice",
- NetworkTab.Liquid => "Liquid Address",
NetworkTab.Import => "Imported Invoice",
_ => "On-Chain Address"
};
@@ -172,7 +169,6 @@ public partial class InvestPageViewModel : ReactiveObject
public string InvoiceTabIcon => SelectedNetworkTab switch
{
NetworkTab.Lightning => "fa-solid fa-bolt",
- NetworkTab.Liquid => "fa-solid fa-droplet",
NetworkTab.Import => "fa-solid fa-file-import",
_ => "fa-brands fa-bitcoin"
};
@@ -352,7 +348,6 @@ public InvestPageViewModel(
{
this.RaisePropertyChanged(nameof(IsOnChainTab));
this.RaisePropertyChanged(nameof(IsLightningTab));
- this.RaisePropertyChanged(nameof(IsLiquidTab));
this.RaisePropertyChanged(nameof(IsImportTab));
this.RaisePropertyChanged(nameof(InvoiceFieldLabel));
this.RaisePropertyChanged(nameof(InvoiceTabIcon));
@@ -820,7 +815,7 @@ public void BackToWalletSelector()
/// Cancels any running monitoring loop before kicking off the new tab's flow.
/// Lightning click triggers Boltz swap creation + monitoring + claim → publish.
/// On-chain click re-enters the address-monitoring loop.
- /// Liquid/Import are visual stubs for now.
+ /// Import is a visual stub for now.
///
public void SelectNetworkTab(NetworkTab tab)
{
@@ -860,9 +855,8 @@ public void SelectNetworkTab(NetworkTab tab)
PaymentStatusText = "Creating Lightning invoice...";
PayViaLightningCommand.Execute().Subscribe();
break;
- case NetworkTab.Liquid:
case NetworkTab.Import:
- // Stubs — no SDK call yet. Modal shows the coming-soon placeholder.
+ // Stub — no SDK call yet. Modal shows the coming-soon placeholder.
break;
}
}
diff --git a/src/design/App/UI/Sections/Funders/FundersView.axaml b/src/design/App/UI/Sections/Funders/FundersView.axaml
index e8166e4c4..d8e5b7dc4 100644
--- a/src/design/App/UI/Sections/Funders/FundersView.axaml
+++ b/src/design/App/UI/Sections/Funders/FundersView.axaml
@@ -402,6 +402,26 @@
+
+
+