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.
362 lines
13 KiB
C#
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);
|
|
});
|
|
}
|