Files
PalladiumWallet/tests/PalladiumWallet.Tests/App/MainWindowViewModelTests.cs
T
davide 530b7feaa5 test(app): add headless ViewModel, CLI and localization tests
ViewModels run on Avalonia.Headless: wizard flows, password and file-lock
handling, seed and private-key reveal gating, contacts, settings, sync and
send against the fake server. The CLI entry point is invoked in-process
end to end. Localization checks cover the 7 languages, placeholders and
keys used in sources. Internal seams (UpdateCheck, ProfileOverride) keep
the tests offline.
2026-11-03 11:45:43 +01:00

362 lines
13 KiB
C#

using Avalonia.Threading;
using NBitcoin;
using PalladiumWallet.App.Localization;
using PalladiumWallet.App.ViewModels;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
using PalladiumWallet.Tests.Net;
namespace PalladiumWallet.Tests.App;
/// <summary>
/// Headless tests of the single application ViewModel. Everything is sandboxed: the data
/// root is a temp folder, the saved server points at an in-process FakeElectrumServer (so the
/// auto-connect never reaches a real server) and the GitHub update check is stubbed.
/// </summary>
[Collection("AppPaths")]
public sealed class MainWindowViewModelTests : VmTestBase
{
// ---- startup, overlays, settings ----
[Fact]
public Task Startup_ShowsTheStartStep_WithNoWalletOpen() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
Assert.True(vm.IsStepStart);
Assert.False(vm.IsWalletOpen);
Assert.True(vm.IsSetupVisible);
Assert.StartsWith("Palladium Wallet", vm.WindowTitle);
Assert.True(vm.IsDesktop);
});
[Fact]
public Task SelectTab_ChangesTheSelectedIndex() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
vm.SelectTabCommand.Execute("3");
Assert.Equal(3, vm.SelectedTabIndex);
});
[Fact]
public Task SettingsAndHelpOverlays_OpenAndClose() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
vm.OpenSettingsCommand.Execute(null);
Assert.True(vm.IsSettingsOpen);
vm.CloseSettingsCommand.Execute(null);
Assert.False(vm.IsSettingsOpen);
vm.OpenHelpCommand.Execute(null);
Assert.True(vm.IsHelpOpen);
vm.CloseHelpCommand.Execute(null);
Assert.False(vm.IsHelpOpen);
});
[Fact]
public Task SetLanguage_SwitchesTheLocInstance_AndPersistsIt() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
var before = vm.Loc;
vm.SetLanguageCommand.Execute("it");
Assert.NotSame(before, vm.Loc); // new instance → bindings re-evaluate
Assert.Equal("it", vm.Loc.Language);
Assert.Equal("it", AppConfig.Load(AppPaths.ConfigPath()).Language);
});
[Fact]
public Task AvailableUpdate_OpensTheUpdateOverlay() => UiSession.Run(async () =>
{
MainWindowViewModel.UpdateCheck = _ => Task.FromResult<LatestRelease?>(new LatestRelease("v9.9.9", "https://example.test/r"));
var vm = new MainWindowViewModel();
await Eventually(() => vm.IsUpdateAvailableOpen);
Assert.Equal("v9.9.9", vm.UpdateAvailableTag);
Assert.Equal("https://example.test/r", vm.UpdateReleaseUrl);
vm.CloseUpdateAvailableCommand.Execute(null);
Assert.False(vm.IsUpdateAvailableOpen);
});
[Fact]
public Task NoUpdate_LeavesTheOverlayClosed() => UiSession.Run(async () =>
{
var vm = new MainWindowViewModel();
await Task.Delay(100);
Dispatcher.UIThread.RunJobs();
Assert.False(vm.IsUpdateAvailableOpen);
});
// ---- wizard ----
[Fact]
public Task Restore_WithInvalidMnemonic_StaysOnTheWordsStep_WithAnError() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
vm.WizardStartRestoreCommand.Execute(null);
vm.MnemonicInput = "abandon abandon abandon";
vm.WizardNextFromWordsCommand.Execute(null);
Assert.True(vm.IsStepWords);
Assert.Equal(Loc.Tr("msg.words.invalid"), vm.StatusMessage);
});
[Fact]
public Task NewWallet_ConfirmingTheWrongSeed_IsRejected() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
vm.WizardStartNewCommand.Execute(null);
Assert.True(vm.IsStepShowSeed);
Assert.True(Bip39.TryParse(vm.MnemonicInput, out _));
vm.WizardNextFromShowSeedCommand.Execute(null);
vm.ConfirmMnemonicInput = Mnemonic; // not the displayed one
vm.WizardNextFromConfirmSeedCommand.Execute(null);
Assert.True(vm.IsStepConfirmSeed);
Assert.Equal(Loc.Tr("msg.seed.mismatch"), vm.StatusMessage);
});
[Fact]
public Task NewWallet_ConfirmingTheRightSeed_AdvancesToThePassphraseStep() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
vm.WizardStartNewCommand.Execute(null);
vm.WizardNextFromShowSeedCommand.Execute(null);
vm.ConfirmMnemonicInput = vm.MnemonicInput.ToUpperInvariant();
vm.WizardNextFromConfirmSeedCommand.Execute(null);
Assert.True(vm.IsStepPassphrase);
});
[Fact]
public Task Password_Mismatch_OrEmpty_DoesNotCreateAnyFile() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
vm.WizardStartRestoreCommand.Execute(null);
vm.MnemonicInput = Mnemonic;
vm.WizardNextFromWordsCommand.Execute(null);
vm.WizardNextFromPassphraseCommand.Execute(null);
vm.WizardNextFromScriptTypeCommand.Execute(null);
vm.PasswordInput = vm.ConfirmPasswordInput = "";
vm.CreateOrRestoreCommand.Execute(null);
Assert.Equal(Loc.Tr("msg.password.required"), vm.StatusMessage);
vm.PasswordInput = "one"; vm.ConfirmPasswordInput = "two";
vm.CreateOrRestoreCommand.Execute(null);
Assert.Equal(Loc.Tr("msg.password.mismatch"), vm.StatusMessage);
Assert.False(vm.IsWalletOpen);
Assert.Empty(AppPaths.WalletFiles(NetKind.Mainnet));
});
[Fact]
public Task Restore_WithPassword_CreatesAnEncryptedWallet_AndOpensIt() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
RestoreFlow(vm, Mnemonic, "s3cret", "main");
Assert.True(vm.IsWalletOpen);
var file = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet));
var content = File.ReadAllText(file);
Assert.True(EncryptedFile.IsEncrypted(content));
Assert.DoesNotContain("abandon", content); // seed never in plaintext on disk
Assert.Contains("NativeSegwit", vm.NetworkInfo);
});
[Fact]
public Task Restore_WithoutEncryption_StoresPlainJson() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
RestoreFlow(vm, Mnemonic, password: null, name: "plain");
Assert.True(vm.IsWalletOpen);
Assert.False(EncryptedFile.IsEncrypted(File.ReadAllText(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet)))));
});
[Fact]
public Task Restore_OverAnExistingName_DoesNotOverwriteTheWallet() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
RestoreFlow(vm, Mnemonic, "pw", "dup");
var path = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet));
var before = File.ReadAllBytes(path);
vm.CloseWalletCommand.Execute(null);
RestoreFlow(vm, Mnemonic, "other", "dup");
Assert.Equal(Loc.Tr("msg.wallet.exists"), vm.StatusMessage);
Assert.Equal(before, File.ReadAllBytes(path));
});
[Fact]
public Task ImportAddress_CreatesAWatchOnlyWallet() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
var address = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet)
.GetReceiveAddress(0).ToString();
vm.WizardStartImportAddressCommand.Execute(null);
vm.ImportAddressInput = address;
vm.WizardNextFromImportAddressCommand.Execute(null);
vm.EncryptWallet = false;
vm.CreateOrRestoreCommand.Execute(null);
Assert.True(vm.IsWalletOpen);
Assert.True(vm.IsWatchOnlyAccount);
});
[Fact]
public Task ImportXpub_CreatesAWatchOnlyWallet_ButANonsenseKeyIsRejected() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
vm.WizardStartImportXkeyCommand.Execute(null);
vm.ImportXkeyInput = "not-an-extended-key";
vm.WizardNextFromImportXkeyCommand.Execute(null);
Assert.True(vm.IsStepImportXkey);
Assert.Equal(Loc.Tr("msg.xkey.invalid"), vm.StatusMessage);
vm.ImportXkeyInput = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet).ToSlip132();
vm.WizardNextFromImportXkeyCommand.Execute(null);
vm.EncryptWallet = false;
vm.CreateOrRestoreCommand.Execute(null);
Assert.True(vm.IsWalletOpen);
Assert.True(vm.IsWatchOnlyAccount);
});
// ---- open existing ----
[Fact]
public Task OpenExisting_WithWrongPassword_ShowsAnError_AndStaysClosed() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
RestoreFlow(vm, Mnemonic, "right", "w1");
vm.CloseWalletCommand.Execute(null);
vm.WizardStartOpenCommand.Execute(null);
vm.PasswordInput = "wrong";
vm.OpenExistingCommand.Execute(null);
Assert.False(vm.IsWalletOpen);
Assert.Equal(Loc.Tr("msg.wrongpassword"), vm.StatusMessage);
});
[Fact]
public Task OpenExisting_WithTheRightPassword_OpensTheWallet() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
RestoreFlow(vm, Mnemonic, "right", "w1");
vm.CloseWalletCommand.Execute(null);
vm.WizardStartOpenCommand.Execute(null);
vm.PasswordInput = "right";
vm.OpenExistingCommand.Execute(null);
Assert.True(vm.IsWalletOpen);
});
[Fact]
public Task OpenExisting_WhileAnotherInstanceHoldsTheLock_IsRefused() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
RestoreFlow(vm, Mnemonic, "pw", "locked");
var path = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet));
// The first VM still holds the exclusive lock: a second instance must not open it.
var second = new MainWindowViewModel();
second.WizardStartOpenCommand.Execute(null);
second.PasswordInput = "pw";
second.OpenExistingCommand.Execute(null);
Assert.False(second.IsWalletOpen);
Assert.Equal(Loc.Tr("msg.wallet.locked"), second.StatusMessage);
Assert.True(File.Exists(path));
});
// ---- contacts ----
[Fact]
public Task Contacts_AreAddedPersistedAndRemoved() => UiSession.Run(() =>
{
var vm = new MainWindowViewModel();
RestoreFlow(vm, Mnemonic, null, "c");
vm.NewContactName = "Alice"; vm.NewContactAddress = "plm1qexample";
vm.AddContactCommand.Execute(null);
vm.NewContactName = ""; vm.NewContactAddress = "ignored";
vm.AddContactCommand.Execute(null); // blank name → ignored
Assert.Single(vm.Contacts);
var stored = WalletStore.Load(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet)));
Assert.Equal("Alice", Assert.Single(stored.Contacts).Name);
vm.SelectedContactInList = vm.Contacts[0];
vm.RemoveSelectedContactCommand.Execute(null);
Assert.Empty(vm.Contacts);
Assert.Empty(WalletStore.Load(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet))).Contacts);
});
// ---- sync + send against the fake server ----
[Fact]
public Task OpeningAFundedWallet_SyncsAgainstTheServer_AndShowsTheVerifiedHistory() => UiSession.Run(async () =>
{
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
WireFundedChain(account.GetReceiveAddress(0), 2_500_000);
var vm = new MainWindowViewModel();
RestoreFlow(vm, Mnemonic, null, "funded");
await Eventually(() => vm.IsConnected && !vm.IsSyncing && vm.History.Count == 1);
Assert.True(vm.History[0].Verified);
Assert.Contains("0.025", vm.BalanceText);
Assert.NotEmpty(vm.Addresses);
Assert.True(_server.CallCount("blockchain.transaction.get_merkle") >= 1); // Merkle proof was demanded
});
[Fact]
public Task Send_PreparesASignedTransaction_ForAFundedWallet_AndRejectsABadAmount() => UiSession.Run(async () =>
{
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
WireFundedChain(account.GetReceiveAddress(0), 50_000_000);
var vm = new MainWindowViewModel();
RestoreFlow(vm, Mnemonic, null, "sender");
await Eventually(() => !vm.IsSyncing && vm.History.Count == 1);
// Coins need MinConfirmations (tip 200, height 100 → 101 confirmations): spendable.
vm.SendTo = account.GetReceiveAddress(7).ToString();
vm.SendAmount = "not-a-number";
await vm.PrepareSendCommand.ExecuteAsync(null);
Assert.Equal(Loc.Tr("msg.amount.invalid"), vm.SendPreview);
Assert.False(vm.HasPendingSend);
vm.SendAmount = "0.1";
await vm.PrepareSendCommand.ExecuteAsync(null);
Assert.True(vm.HasPendingSend, vm.SendPreview);
Assert.Contains("txid", vm.SendPreview);
vm.SendAmount = "999";
await vm.PrepareSendCommand.ExecuteAsync(null);
Assert.False(vm.HasPendingSend);
Assert.Contains(Loc.Tr("msg.error"), vm.SendPreview);
});
[Fact]
public Task Send_BeforeAnySync_AsksToSyncFirst() => UiSession.Run(async () =>
{
var vm = new MainWindowViewModel();
await vm.PrepareSendCommand.ExecuteAsync(null);
Assert.Equal(Loc.Tr("msg.send.sync.first"), vm.SendPreview);
});
}